diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 618a6688..e3b34241 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,14 @@ jobs: needs: [fmt, clippy, security-gate] steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 0 + - name: Enforce retrieval and performance budgets + run: >- + python3 scripts/check-quality-budgets.py + --budgets docs/quality/budgets.json + --retrieval tests/fixtures/retrieval_quality/baseline.json + --criterion benches/results/benchmark_results.txt - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable 2026-07-12 with: targets: wasm32-unknown-unknown @@ -504,7 +512,7 @@ jobs: bench: name: Benchmark Regression Check runs-on: ubuntu-latest - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'pull_request' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: write pull-requests: write @@ -513,15 +521,22 @@ jobs: - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable 2026-07-12 - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - - name: Run benchmarks (Criterion JSON output) - run: cargo bench -- --output-format bencher | tee bench-output.txt + - name: Run benchmarks (Criterion bencher output) + shell: bash + run: | + set -o pipefail + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + cargo bench --bench entity_extraction -- --output-format bencher + else + cargo bench -- --output-format bencher + fi | tee bench-output.txt - name: Store benchmark results uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1 # Tolerate missing gh-pages branch on first run / forks. The raw # criterion artifacts still upload via the next step, so PR authors # can inspect benchmark output even before history is established. - continue-on-error: true + continue-on-error: ${{ github.event_name != 'pull_request' }} with: name: Engram Performance tool: cargo diff --git a/.github/workflows/python-sdk-live.yml b/.github/workflows/python-sdk-live.yml new file mode 100644 index 00000000..222e0e12 --- /dev/null +++ b/.github/workflows/python-sdk-live.yml @@ -0,0 +1,46 @@ +name: Python SDK Live + +on: + pull_request: + branches: [main] + paths: + - 'sdks/python/**' + - 'scripts/test-python-sdk-live.sh' + - '.github/workflows/python-sdk-live.yml' + - 'src/**' + - 'Cargo.toml' + - 'Cargo.lock' + push: + branches: [main] + paths: + - 'sdks/python/**' + - 'scripts/test-python-sdk-live.sh' + - '.github/workflows/python-sdk-live.yml' + - 'src/**' + - 'Cargo.toml' + - 'Cargo.lock' + workflow_dispatch: + +permissions: + contents: read + +jobs: + live-package-contract: + name: Python SDK installed-wheel live contract + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable 2026-07-12 + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler python3-venv + - name: Run installed-wheel live contract + run: bash scripts/test-python-sdk-live.sh + - name: Run Python SDK unit suite + run: | + python3 -m venv .python-sdk-unit + .python-sdk-unit/bin/python -m pip install --quiet --upgrade pip + .python-sdk-unit/bin/python -m pip install --quiet -e 'sdks/python[dev]' + .python-sdk-unit/bin/python -m pytest sdks/python/tests -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a872836..fa0519b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,73 +6,142 @@ on: - "v*" workflow_dispatch: inputs: - version: - description: "Version tag to release (e.g. v0.6.1)" + dry_run: + description: "Build and attest the complete matrix without publishing" + required: true + default: true + type: boolean + sha: + description: "Exact commit SHA that this run must build" required: true type: string + version: + description: "Artifact version label (for example v0.22.0)" + required: false + default: "v0.22.0" + type: string overwrite_assets: - description: "Allow replacing assets when the GitHub Release already exists" + description: "Allow replacing assets on an existing GitHub Release" required: false default: false type: boolean + homebrew_only: + description: "Update Homebrew from an already verified GitHub Release" + required: false + default: false + type: boolean + tag: + description: "Exact protected release tag for a Homebrew-only run" + required: false + type: string permissions: - contents: write + contents: read env: CARGO_TERM_COLOR: always FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - RELEASE_VERSION: ${{ github.event.inputs.version || github.ref_name }} jobs: preflight: name: Release Preflight runs-on: ubuntu-latest + outputs: + dry_run: ${{ steps.guard.outputs.dry_run }} + homebrew_only: ${{ steps.guard.outputs.homebrew_only }} + release_version: ${{ steps.guard.outputs.release_version }} + release_sha: ${{ steps.guard.outputs.release_sha }} steps: - - name: Validate release version + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + + - name: Validate event, ref, and SHA before any write + id: guard + env: + INPUT_DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + INPUT_SHA: ${{ github.event.inputs.sha || github.sha }} + INPUT_VERSION: ${{ github.event.inputs.version || github.ref_name }} + INPUT_HOMEBREW_ONLY: ${{ github.event.inputs.homebrew_only || 'false' }} + INPUT_TAG: ${{ github.event.inputs.tag || '' }} + INPUT_OVERWRITE: ${{ github.event.inputs.overwrite_assets || 'false' }} run: | - if [[ ! "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::Invalid release version" + set -euo pipefail + + if [[ ! "${INPUT_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::sha must be a full lowercase commit SHA" + exit 1 + fi + if [[ "${INPUT_SHA}" != "${GITHUB_SHA}" ]]; then + echo "::error::supplied SHA does not match github.sha" + exit 1 + fi + if [[ "$(git rev-parse HEAD)" != "${INPUT_SHA}" ]]; then + echo "::error::checked-out commit does not match supplied SHA" + exit 1 + fi + if [[ ! "${INPUT_VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version must be an exact stable semver tag" exit 1 fi - - name: Check release immutability + if [[ "${INPUT_DRY_RUN}" == "true" ]]; then + if [[ "${INPUT_HOMEBREW_ONLY}" == "true" || "${INPUT_OVERWRITE}" == "true" ]]; then + echo "::error::dry-run cannot request a write mode" + exit 1 + fi + else + if [[ "${GITHUB_REF_TYPE}" != "tag" || "${GITHUB_REF_NAME}" != "${INPUT_VERSION}" ]]; then + echo "::error::live release requires the exact version tag ref" + exit 1 + fi + if [[ "$(git rev-list -n 1 "${INPUT_VERSION}^{commit}")" != "${INPUT_SHA}" ]]; then + echo "::error::tag, github.sha, and supplied SHA do not agree" + exit 1 + fi + fi + + if [[ "${INPUT_HOMEBREW_ONLY}" == "true" ]]; then + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" || "${INPUT_TAG}" != "${INPUT_VERSION}" ]]; then + echo "::error::Homebrew writes require an explicit exact-tag dispatch" + exit 1 + fi + elif [[ -n "${INPUT_TAG}" ]]; then + echo "::error::tag input is valid only for homebrew_only" + exit 1 + fi + + { + echo "dry_run=${INPUT_DRY_RUN}" + echo "homebrew_only=${INPUT_HOMEBREW_ONLY}" + echo "release_version=${INPUT_VERSION}" + echo "release_sha=${INPUT_SHA}" + } >> "${GITHUB_OUTPUT}" + + - name: Check GitHub Release immutability + if: steps.guard.outputs.dry_run == 'false' && steps.guard.outputs.homebrew_only == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} OVERWRITE_ASSETS: ${{ github.event.inputs.overwrite_assets || 'false' }} + RELEASE_VERSION: ${{ steps.guard.outputs.release_version }} run: | set -euo pipefail - - response_file="$(mktemp)" - status_code="$( - curl -sS \ - -o "${response_file}" \ - -w "%{http_code}" \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_VERSION}" - )" - - if [ "${status_code}" = "200" ]; then - if [ "${OVERWRITE_ASSETS}" = "true" ]; then - echo "::warning::Release ${RELEASE_VERSION} already exists; overwrite_assets=true allows asset replacement." - else - echo "::error::Release ${RELEASE_VERSION} already exists. Re-run with overwrite_assets=true only if replacing release assets and Homebrew checksums is intentional." + if gh release view "${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + if [[ "${OVERWRITE_ASSETS}" != "true" ]]; then + echo "::error::release already exists; overwrite was not explicitly approved" exit 1 fi - elif [ "${status_code}" = "404" ]; then - echo "Release ${RELEASE_VERSION} does not exist; continuing." - else - echo "::error::Could not verify release ${RELEASE_VERSION}; GitHub API returned HTTP ${status_code}." - cat "${response_file}" - exit 1 fi build: name: Build (${{ matrix.target }}) needs: preflight + if: needs.preflight.outputs.homebrew_only == 'false' runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write + attestations: write strategy: fail-fast: false matrix: @@ -89,7 +158,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ env.RELEASE_VERSION }} + ref: ${{ needs.preflight.outputs.release_sha }} - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: @@ -108,151 +177,175 @@ jobs: echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml - name: Build release binaries - run: cargo build --release --target ${{ matrix.target }} --features pdf --bin engram-server --bin engram-cli --bin engram-pdf-worker + run: cargo build --locked --release --target ${{ matrix.target }} --features pdf --bin engram-server --bin engram-cli --bin engram-pdf-worker - - name: Package (Unix) + - name: Package deterministic archive and supply-chain evidence + env: + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} + RELEASE_REF: ${{ github.ref }} + RELEASE_TARGET: ${{ matrix.target }} run: | - ARCHIVE="engram-${RELEASE_VERSION}-${{ matrix.target }}.tar.gz" - cd target/${{ matrix.target }}/release - if [[ "${{ matrix.target }}" == *-linux-* ]]; then - tar -czf "../../../${ARCHIVE}" engram-server engram-cli engram-pdf-worker - else - tar -czf "../../../${ARCHIVE}" engram-server engram-cli - fi - cd ../../.. - shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256" + set -euo pipefail + scripts/verify-release-artifacts.sh \ + --create-bundle \ + --binary-dir "target/${RELEASE_TARGET}/release" \ + --output-dir release-bundle \ + --target "${RELEASE_TARGET}" \ + --version "${RELEASE_VERSION}" \ + --sha "${RELEASE_SHA}" \ + --ref "${RELEASE_REF}" + scripts/verify-release-artifacts.sh \ + --artifact-dir release-bundle \ + --expected-sha "${RELEASE_SHA}" \ + --target "${RELEASE_TARGET}" \ + --allow-untrusted-local + scripts/test-release-binary.sh \ + --artifact-dir release-bundle \ + --target "${RELEASE_TARGET}" \ + --allow-cross + + - name: Sign bundle with GitHub Actions identity + id: github-attestation + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: release-bundle/* + show-summary: false + + - name: Bind and verify GitHub identity attestation + env: + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_TARGET: ${{ matrix.target }} + RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} + GITHUB_ATTESTATION_BUNDLE: ${{ steps.github-attestation.outputs.bundle-path }} + run: | + set -euo pipefail + archive="release-bundle/engram-${RELEASE_VERSION}-${RELEASE_TARGET}.tar.gz" + cp "${GITHUB_ATTESTATION_BUNDLE}" "${archive}.github-attestation.sigstore.json" + scripts/verify-release-artifacts.sh \ + --artifact-dir release-bundle \ + --expected-sha "${RELEASE_SHA}" \ + --target "${RELEASE_TARGET}" - - name: Upload artifact + - name: Upload attested artifact bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ${{ matrix.target }} - path: | - engram-${{ env.RELEASE_VERSION }}-${{ matrix.target }}.tar.gz - engram-${{ env.RELEASE_VERSION }}-${{ matrix.target }}.tar.gz.sha256 + name: core-${{ matrix.target }} + if-no-files-found: error + path: release-bundle/ - release: - name: Create Release - needs: build + dry-run-summary: + name: Verify dry-run matrix + needs: [preflight, build] + if: needs.preflight.outputs.dry_run == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ env.RELEASE_VERSION }} - - - name: Download all artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + ref: ${{ needs.preflight.outputs.release_sha }} + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: + pattern: core-* path: artifacts + - name: Verify exact matrix, checksums, SBOMs, signature, and provenance + run: scripts/verify-release-artifacts.sh --artifact-dir artifacts --expected-sha "${{ needs.preflight.outputs.release_sha }}" + - name: Assert dry-run cannot publish + run: | + test "${{ needs.preflight.outputs.dry_run }}" = true + test "${{ needs.preflight.outputs.homebrew_only }}" = false + echo "Dry-run complete: no tag, GitHub Release, registry, or Homebrew write was attempted." + release: + name: Create GitHub Release + needs: [preflight, build] + if: needs.preflight.outputs.dry_run == 'false' && needs.preflight.outputs.homebrew_only == 'false' + runs-on: ubuntu-latest + permissions: + contents: write + environment: release-github + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + pattern: core-* + path: artifacts + - name: Verify all release inputs before publication + run: scripts/verify-release-artifacts.sh --artifact-dir artifacts --expected-sha "${{ needs.preflight.outputs.release_sha }}" - name: Collect release files + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} run: | - mkdir -p release - find artifacts -type f \( -name '*.tar.gz' -o -name '*.sha256' \) -exec cp {} release/ \; - echo "## SHA256 Checksums" > release/CHECKSUMS.md - echo '```' >> release/CHECKSUMS.md - cat release/*.sha256 >> release/CHECKSUMS.md - echo '```' >> release/CHECKSUMS.md - cat release/CHECKSUMS.md + set -euo pipefail + mkdir release + find artifacts -type f -exec cp {} release/ \; .github/scripts/extract-release-notes.sh "${RELEASE_VERSION}" CHANGELOG.md release/RELEASE_NOTES.md - cat release/RELEASE_NOTES.md - - name: Create GitHub Release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: - tag_name: ${{ env.RELEASE_VERSION }} + tag_name: ${{ needs.preflight.outputs.release_version }} draft: false body_path: release/RELEASE_NOTES.md - files: | - release/*.tar.gz - release/*.sha256 + overwrite_files: ${{ github.event.inputs.overwrite_assets || false }} + files: release/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} update-homebrew: name: Update Homebrew Formula - needs: release + needs: preflight + if: needs.preflight.outputs.dry_run == 'false' && needs.preflight.outputs.homebrew_only == 'true' runs-on: ubuntu-latest + environment: release-homebrew env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} steps: - - name: Check Homebrew token - id: homebrew-token - run: | - if [ -n "${HOMEBREW_TAP_TOKEN}" ]; then - echo "available=true" >> "${GITHUB_OUTPUT}" - else - echo "available=false" >> "${GITHUB_OUTPUT}" - echo "::notice::HOMEBREW_TAP_TOKEN is not configured; skipping Homebrew formula update." - fi - - - name: Download SHA256 files - if: steps.homebrew-token.outputs.available == 'true' - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - path: artifacts - - - name: Checkout Engram release source - if: steps.homebrew-token.outputs.available == 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: path: engram-source - ref: ${{ env.RELEASE_VERSION }} - - - name: Checkout homebrew-engram - if: steps.homebrew-token.outputs.available == 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + ref: ${{ needs.preflight.outputs.release_sha }} + - name: Require protected Homebrew credential + run: test -n "${HOMEBREW_TAP_TOKEN}" + - name: Download and verify the existing immutable GitHub Release + run: | + mkdir artifacts + gh release download "${RELEASE_VERSION}" --repo "${GITHUB_REPOSITORY}" --dir artifacts + engram-source/scripts/verify-github-release.sh \ + --tag "${RELEASE_VERSION}" \ + --sha "${RELEASE_SHA}" \ + --artifact-dir artifacts + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: repository: aiconnai/homebrew-engram token: ${{ env.HOMEBREW_TAP_TOKEN }} path: homebrew-engram - - - name: Update formula - if: steps.homebrew-token.outputs.available == 'true' + - name: Update the formula from verified checksums run: | - REF="$RELEASE_VERSION" - VERSION="${REF#v}" - - # Read SHA256 hashes - ARM64_MAC=$(cat artifacts/aarch64-apple-darwin/*.sha256 | awk '{print $1}') - X86_64_MAC=$(cat artifacts/x86_64-apple-darwin/*.sha256 | awk '{print $1}') - ARM64_LINUX=$(cat artifacts/aarch64-unknown-linux-gnu/*.sha256 | awk '{print $1}') - X86_64_LINUX=$(cat artifacts/x86_64-unknown-linux-gnu/*.sha256 | awk '{print $1}') - + set -euo pipefail + version="${RELEASE_VERSION#v}" + arm64_mac="$(awk '{print $1}' "artifacts/engram-${RELEASE_VERSION}-aarch64-apple-darwin.tar.gz.sha256")" + x86_64_mac="$(awk '{print $1}' "artifacts/engram-${RELEASE_VERSION}-x86_64-apple-darwin.tar.gz.sha256")" + arm64_linux="$(awk '{print $1}' "artifacts/engram-${RELEASE_VERSION}-aarch64-unknown-linux-gnu.tar.gz.sha256")" + x86_64_linux="$(awk '{print $1}' "artifacts/engram-${RELEASE_VERSION}-x86_64-unknown-linux-gnu.tar.gz.sha256")" cd homebrew-engram - - # Update version - sed -i "s/version \".*\"/version \"${VERSION}\"/" Formula/engram.rb - - # Update SHA256 hashes (replace any existing hash or placeholder) - # macOS arm64 - sed -i "/aarch64-apple-darwin/{ n; s/sha256 \".*\"/sha256 \"${ARM64_MAC}\"/ }" Formula/engram.rb - # macOS x86_64 - sed -i "/x86_64-apple-darwin/{ n; s/sha256 \".*\"/sha256 \"${X86_64_MAC}\"/ }" Formula/engram.rb - # Linux arm64 - sed -i "/aarch64-unknown-linux-gnu/{ n; s/sha256 \".*\"/sha256 \"${ARM64_LINUX}\"/ }" Formula/engram.rb - # Linux x86_64 - sed -i "/x86_64-unknown-linux-gnu/{ n; s/sha256 \".*\"/sha256 \"${X86_64_LINUX}\"/ }" Formula/engram.rb - + sed -i "s/version \".*\"/version \"${version}\"/" Formula/engram.rb + sed -i "/aarch64-apple-darwin/{ n; s/sha256 \".*\"/sha256 \"${arm64_mac}\"/; }" Formula/engram.rb + sed -i "/x86_64-apple-darwin/{ n; s/sha256 \".*\"/sha256 \"${x86_64_mac}\"/; }" Formula/engram.rb + sed -i "/aarch64-unknown-linux-gnu/{ n; s/sha256 \".*\"/sha256 \"${arm64_linux}\"/; }" Formula/engram.rb + sed -i "/x86_64-unknown-linux-gnu/{ n; s/sha256 \".*\"/sha256 \"${x86_64_linux}\"/; }" Formula/engram.rb python3 ../engram-source/.github/scripts/ensure-pdf-worker-homebrew.py Formula/engram.rb - if grep -F 'bin.install "engram-pdf-worker"' Formula/engram.rb >/dev/null; then - echo "macOS formula must not install unsupported PDF worker" >&2 - exit 1 - fi - - echo "Updated formula:" - cat Formula/engram.rb - - - name: Commit and push - if: steps.homebrew-token.outputs.available == 'true' + git diff --check + - name: Commit and push the sole formula change run: | - REF="$RELEASE_VERSION" + set -euo pipefail cd homebrew-engram + test "$(git diff --name-only | tr -d '\n')" = "Formula/engram.rb" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add Formula/engram.rb - if git diff --cached --quiet; then - echo "No Homebrew formula changes" - exit 0 - fi - git commit -m "chore: update engram to ${REF}" + git commit -m "chore: update engram to ${RELEASE_VERSION}" git push diff --git a/.github/workflows/sdk-release.yml b/.github/workflows/sdk-release.yml new file mode 100644 index 00000000..4a0bbef7 --- /dev/null +++ b/.github/workflows/sdk-release.yml @@ -0,0 +1,273 @@ +name: SDK Release + +on: + workflow_dispatch: + inputs: + channel: + description: "SDK channel to build or publish" + required: true + default: all + type: choice + options: [all, python, npm] + dry_run: + description: "Build and verify packages without registry access" + required: true + default: true + type: boolean + sha: + description: "Exact commit SHA that this run must build" + required: true + type: string + publish: + description: "Request one protected registry write" + required: true + default: false + type: boolean + tag: + description: "Exact protected core release tag for a publish run" + required: false + type: string + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + preflight: + name: SDK Release Preflight + runs-on: ubuntu-latest + outputs: + channel: ${{ steps.guard.outputs.channel }} + dry_run: ${{ steps.guard.outputs.dry_run }} + publish: ${{ steps.guard.outputs.publish }} + release_sha: ${{ steps.guard.outputs.release_sha }} + release_tag: ${{ steps.guard.outputs.release_tag }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + - name: Validate event, ref, channel, and SHA before registry access + id: guard + env: + INPUT_CHANNEL: ${{ inputs.channel }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + INPUT_SHA: ${{ inputs.sha }} + INPUT_PUBLISH: ${{ inputs.publish }} + INPUT_TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]] || { echo "::error::SDK releases require explicit dispatch"; exit 1; } + [[ "${INPUT_CHANNEL}" =~ ^(all|python|npm)$ ]] || { echo "::error::invalid SDK channel"; exit 1; } + [[ "${INPUT_SHA}" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::sha must be a full lowercase commit SHA"; exit 1; } + [[ "${INPUT_SHA}" == "${GITHUB_SHA}" ]] || { echo "::error::supplied SHA does not match github.sha"; exit 1; } + [[ "$(git rev-parse HEAD)" == "${INPUT_SHA}" ]] || { echo "::error::checked-out commit does not match supplied SHA"; exit 1; } + + if [[ "${INPUT_DRY_RUN}" == "true" ]]; then + [[ "${INPUT_PUBLISH}" == "false" ]] || { echo "::error::dry-run cannot publish"; exit 1; } + [[ -z "${INPUT_TAG}" ]] || { echo "::error::dry-run does not accept a release tag"; exit 1; } + else + [[ "${INPUT_PUBLISH}" == "true" ]] || { echo "::error::non-dry-run must explicitly request publish"; exit 1; } + [[ "${INPUT_CHANNEL}" != "all" ]] || { echo "::error::publish dispatch must select exactly one registry"; exit 1; } + [[ "${INPUT_TAG}" == "v0.22.0" ]] || { echo "::error::publish requires the approved v0.22.0 tag"; exit 1; } + [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == "${INPUT_TAG}" ]] || { echo "::error::publish requires the exact tag ref"; exit 1; } + [[ "$(git cat-file -t "${INPUT_TAG}")" == "tag" ]] || { echo "::error::publish requires an annotated tag object"; exit 1; } + git verify-tag "${INPUT_TAG}" >/dev/null || { echo "::error::publish requires a cryptographically signed tag"; exit 1; } + [[ "$(git rev-list -n 1 "${INPUT_TAG}^{commit}")" == "${INPUT_SHA}" ]] || { echo "::error::tag, github.sha, and supplied SHA do not agree"; exit 1; } + gh release view "${INPUT_TAG}" --repo "${GITHUB_REPOSITORY}" --json isDraft,isPrerelease,tagName --jq 'select(.isDraft == false and .isPrerelease == false and .tagName == "v0.22.0")' | grep -q . || { + echo "::error::approved immutable GitHub Release is missing"; exit 1; + } + fi + { + echo "channel=${INPUT_CHANNEL}" + echo "dry_run=${INPUT_DRY_RUN}" + echo "publish=${INPUT_PUBLISH}" + echo "release_sha=${INPUT_SHA}" + echo "release_tag=${INPUT_TAG}" + } >>"${GITHUB_OUTPUT}" + + - name: Verify package names and target-version availability read-only + env: + CHANNEL: ${{ steps.guard.outputs.channel }} + DRY_RUN: ${{ steps.guard.outputs.dry_run }} + run: | + set -euo pipefail + if [[ "${CHANNEL}" == "all" || "${CHANNEL}" == "python" ]]; then + python3 -c 'import json,urllib.request; payload=json.load(urllib.request.urlopen("https://pypi.org/pypi/engram-client/json",timeout=20)); assert payload.get("info",{}).get("name","").lower().replace("_","-")=="engram-client", "PyPI package-name verification failed"' + if [[ "${DRY_RUN}" == "false" ]] && curl --fail --silent --output /dev/null "https://pypi.org/pypi/engram-client/0.5.0/json"; then + echo "::error::PyPI version 0.5.0 already exists" + exit 1 + fi + fi + if [[ "${CHANNEL}" == "all" || "${CHANNEL}" == "npm" ]]; then + test "$(npm view engram-client name)" = "engram-client" + if [[ "${DRY_RUN}" == "false" ]] && npm view engram-client@0.5.0 version >/dev/null 2>&1; then + echo "::error::npm version 0.5.0 already exists" + exit 1 + fi + fi + + build-python: + name: Build Python SDK + needs: preflight + if: needs.preflight.outputs.channel == 'all' || needs.preflight.outputs.channel == 'python' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + - name: Build and check wheel and sdist + env: + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + run: | + set -euo pipefail + python3 -m pip install --disable-pip-version-check 'build>=1,<2' 'twine>=6,<7' + python3 -m build --outdir sdk-artifacts/python sdks/python + python3 -m twine check sdk-artifacts/python/* + scripts/verify-sdk-artifacts.sh --write-metadata python --artifact-dir sdk-artifacts/python --expected-sha "${RELEASE_SHA}" + - name: Verify installable Python package artifacts + run: scripts/verify-sdk-artifacts.sh --channel python --artifact-dir sdk-artifacts --expected-sha "${{ needs.preflight.outputs.release_sha }}" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: sdk-python + if-no-files-found: error + path: sdk-artifacts/python/ + + build-npm: + name: Build TypeScript SDK + needs: preflight + if: needs.preflight.outputs.channel == 'all' || needs.preflight.outputs.channel == 'npm' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: sdks/typescript/package-lock.json + - name: Build, test, and pack npm tarball + env: + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + run: | + set -euo pipefail + cd sdks/typescript + npm ci --ignore-scripts --no-audit --no-fund + npm run type-check + npm test + npm run build + mkdir -p ../../sdk-artifacts/npm + npm pack --pack-destination ../../sdk-artifacts/npm + cd ../.. + scripts/verify-sdk-artifacts.sh --write-metadata npm --artifact-dir sdk-artifacts/npm --expected-sha "${RELEASE_SHA}" + - name: Verify installable npm package artifact + run: scripts/verify-sdk-artifacts.sh --channel npm --artifact-dir sdk-artifacts --expected-sha "${{ needs.preflight.outputs.release_sha }}" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: sdk-npm + if-no-files-found: error + path: sdk-artifacts/npm/ + + dry-run-summary: + name: Verify SDK dry-run + needs: [preflight, build-python, build-npm] + if: always() && needs.preflight.result == 'success' && needs.preflight.outputs.dry_run == 'true' && (needs.build-python.result == 'success' || needs.build-python.result == 'skipped') && (needs.build-npm.result == 'success' || needs.build-npm.result == 'skipped') + runs-on: ubuntu-latest + steps: + - name: Assert dry-run terminal state + run: | + test "${{ needs.preflight.outputs.publish }}" = false + echo "Dry-run complete: no registry request was attempted." + + publish-python: + name: Publish Python SDK + needs: [preflight, build-python] + if: needs.preflight.outputs.dry_run == 'false' && needs.preflight.outputs.publish == 'true' && needs.preflight.outputs.channel == 'python' + runs-on: ubuntu-latest + environment: release-pypi + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + ref: ${{ needs.preflight.outputs.release_sha }} + - name: Revalidate protected tag and release after environment approval + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }} + run: | + set -euo pipefail + test "${GITHUB_SHA}" = "${RELEASE_SHA}" + test "${GITHUB_REF_TYPE}" = tag + test "${GITHUB_REF_NAME}" = "${RELEASE_TAG}" + test "$(git rev-parse HEAD)" = "${RELEASE_SHA}" + test "$(git cat-file -t "${RELEASE_TAG}")" = tag + git verify-tag "${RELEASE_TAG}" >/dev/null + test "$(git rev-list -n 1 "${RELEASE_TAG}^{commit}")" = "${RELEASE_SHA}" + gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json isDraft,isPrerelease,tagName --jq 'select(.isDraft == false and .isPrerelease == false and .tagName == "v0.22.0")' | grep -q . + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: sdk-python + path: dist + - name: Reverify the exact candidate before registry access + run: scripts/verify-sdk-artifacts.sh --channel python --artifact-dir dist --expected-sha "${{ needs.preflight.outputs.release_sha }}" + - name: Prepare distributions only + run: | + mkdir -p dist/packages + find dist -maxdepth 1 -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp {} dist/packages/ \; + - name: Publish with PyPI trusted publishing + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + packages-dir: dist/packages/ + + publish-npm: + name: Publish TypeScript SDK + needs: [preflight, build-npm] + if: needs.preflight.outputs.dry_run == 'false' && needs.preflight.outputs.publish == 'true' && needs.preflight.outputs.channel == 'npm' + runs-on: ubuntu-latest + environment: release-npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + ref: ${{ needs.preflight.outputs.release_sha }} + - name: Revalidate protected tag and release after environment approval + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }} + run: | + set -euo pipefail + test "${GITHUB_SHA}" = "${RELEASE_SHA}" + test "${GITHUB_REF_TYPE}" = tag + test "${GITHUB_REF_NAME}" = "${RELEASE_TAG}" + test "$(git rev-parse HEAD)" = "${RELEASE_SHA}" + test "$(git cat-file -t "${RELEASE_TAG}")" = tag + git verify-tag "${RELEASE_TAG}" >/dev/null + test "$(git rev-list -n 1 "${RELEASE_TAG}^{commit}")" = "${RELEASE_SHA}" + gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json isDraft,isPrerelease,tagName --jq 'select(.isDraft == false and .isPrerelease == false and .tagName == "v0.22.0")' | grep -q . + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: sdk-npm + path: dist + - name: Reverify the exact candidate before registry access + run: scripts/verify-sdk-artifacts.sh --channel npm --artifact-dir dist --expected-sha "${{ needs.preflight.outputs.release_sha }}" + - name: Publish exact verified tarball with npm trusted publishing + run: | + set -euo pipefail + tarball="$(find dist -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "${tarball}" + npm publish "${tarball}" --access public --provenance diff --git a/.github/workflows/typescript-sdk-live.yml b/.github/workflows/typescript-sdk-live.yml new file mode 100644 index 00000000..2ad5bf2f --- /dev/null +++ b/.github/workflows/typescript-sdk-live.yml @@ -0,0 +1,50 @@ +name: TypeScript SDK Live + +on: + pull_request: + branches: [main] + paths: + - "sdks/typescript/**" + - "scripts/test-typescript-sdk-live.sh" + - ".github/workflows/typescript-sdk-live.yml" + - "src/**" + - "Cargo.toml" + - "Cargo.lock" + push: + branches: [main] + paths: + - "sdks/typescript/**" + - "scripts/test-typescript-sdk-live.sh" + - ".github/workflows/typescript-sdk-live.yml" + - "src/**" + - "Cargo.toml" + - "Cargo.lock" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + live-contract: + name: Packed TypeScript SDK contract + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: sdks/typescript/package-lock.json + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable 2026-07-12 + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + - name: Verify packed SDK against authenticated server + run: bash scripts/test-typescript-sdk-live.sh + - name: Reject wrong bearer + run: bash scripts/test-typescript-sdk-live.sh --self-test-wrong-bearer + - name: Reject missing endpoint + run: bash scripts/test-typescript-sdk-live.sh --self-test-missing-endpoint diff --git a/README.md b/README.md index 3c63d0fb..bd5626c4 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,10 @@ server configured as usual. | `ENGRAM_WS_PORT` | WebSocket server port (0 = disabled) | `0` | | `ENGRAM_WS_BIND_ADDRESS` | WebSocket server bind address | `127.0.0.1` | | `ENGRAM_WS_AUTH_KEY` | Bearer token for WebSocket upgrade authentication | - | +| `ENGRAM_WS_ALLOWED_ORIGINS` | Comma-separated exact browser origins allowed for WebSocket upgrades (no wildcard) | - | +| `ENGRAM_WS_MAX_CONNECTIONS` | Maximum simultaneous upgraded WebSocket connections | `128` | +| `ENGRAM_WS_MAX_MESSAGE_BYTES` | Maximum bytes in one inbound WebSocket frame or message | `65536` | +| `ENGRAM_WS_READ_IDLE_TIMEOUT_SECONDS` | Maximum post-handshake silence between inbound frames | `60` | | `ENGRAM_GRPC_PORT` | gRPC transport port (requires `--features grpc`) | `50051` | | `ENGRAM_GRPC_BIND_ADDRESS` | gRPC transport bind address (requires `--features grpc`) | `127.0.0.1` | | `ENGRAM_GRPC_API_KEY` | Bearer token for the gRPC transport | - | @@ -339,7 +343,15 @@ and gRPC network listeners bind `127.0.0.1` by default. Explicit public binds require the operator to configure the relevant bind-address variable and deploy the matching authentication control; public WebSocket binds require `ENGRAM_WS_AUTH_KEY` even behind a trusted proxy. MCP stdio remains the -default agent interface. +default agent interface. WebSocket subscriptions select one workspace with +`/ws?workspace=` (default: `default`); browser clients must send an Origin +listed exactly in `ENGRAM_WS_ALLOWED_ORIGINS`, while non-browser clients may +omit the Origin header. Resource limits default to 128 simultaneous connections, +64 KiB per inbound frame/message, and a 60-second read-idle timeout. A saturated +listener rejects the upgrade with HTTP `503`; oversized clients close with +WebSocket code `1009`, and read-idle clients close with code `1008`. Aggregate +resource counters are exposed by the WebSocket listener's `/health` response; +they never include principals, credentials, workspaces, or message content. --- diff --git a/docs/MCP_AUTH.md b/docs/MCP_AUTH.md index 7a0998c9..c528c3c8 100644 --- a/docs/MCP_AUTH.md +++ b/docs/MCP_AUTH.md @@ -54,6 +54,33 @@ HTTP auth is evaluated before MCP request rate limiting. Unauthorized requests do not consume rate-limit tokens and should continue to return `401` even when a bucket for the same client identity is exhausted. +## HTTP resource bounds + +MCP request bodies and the time spent establishing MCP/SSE responses are +bounded by default: + +- `ENGRAM_HTTP_MAX_BODY_BYTES` (default: `1048576`, or 1 MiB) +- `ENGRAM_HTTP_REQUEST_TIMEOUT_MS` (default: `30000`, or 30 seconds) + +Both settings must be positive integers. Invalid values and `0` fail closed to +the documented safe defaults; there is no unlimited sentinel. Requests larger +than the configured body limit return `413 Payload Too Large` before JSON +parsing. Requests that do not complete within the configured lifetime return +`408 Request Timeout`; MCP responses use JSON-RPC code `-32008` and message +`Request Timeout`. + +Bearer authentication runs before request-body collection and parsing, so an +unauthenticated oversized request returns `401`, not `413`. The timeout covers +authentication, body collection, JSON extraction, and asynchronous response +setup. Engram's current MCP handler trait is synchronous; once handler dispatch +begins it cannot be safely preempted, so the deadline prevents slow/incomplete +requests from reaching it rather than claiming unsafe cancellation. For +`GET /v1/events`, the timeout covers authentication and SSE setup only; an +established event stream remains long-lived and is governed by its +keepalive/reconnection behavior. A notification that times out before parsing +cannot yet be identified as a notification and receives the same stable +JSON-RPC `-32008` timeout response; parsed notifications complete with `202`. + ## Rate limiting (HTTP MCP) Engram can enforce a token-bucket rate limit for MCP HTTP requests: diff --git a/docs/contracts/advertised-surfaces.toml b/docs/contracts/advertised-surfaces.toml index bf9b05e3..e4355fc4 100644 --- a/docs/contracts/advertised-surfaces.toml +++ b/docs/contracts/advertised-surfaces.toml @@ -51,7 +51,7 @@ council_skill_methods = ["ask", "askWithPersistence"] [docs_contract] channels = ["MCP stdio", "HTTP MCP", "WebSocket events", "gRPC", "CLI", "Python SDK", "TypeScript SDK"] http_endpoints = ["DELETE /api-keys/:id", "DELETE /v1/images?memory_id=...", "DELETE /v1/images/:key", "DELETE /v1/memories/:id", "GET /api-keys", "GET /health", "GET /ready", "GET /tenants", "GET /tenants/:slug", "GET /v1/events", "GET /v1/images?memory_id=...", "GET /v1/images/:key", "GET /v1/memories/:id", "GET /v1/usage", "PATCH /v1/memories/:id", "POST /api-keys", "POST /invites", "POST /invites/accept", "POST /mcp", "POST /tenants", "POST /v1/images", "POST /v1/images/base64", "POST /v1/mcp", "POST /v1/mcp/batch", "POST /v1/memories", "POST /v1/search"] -env_vars = ["AWS_ENDPOINT_URL", "AWS_PROFILE", "ENGRAM_CLEANUP_INTERVAL", "ENGRAM_CLOUD_ENCRYPT", "ENGRAM_CORS_ORIGINS", "ENGRAM_DB_PATH", "ENGRAM_EMBEDDING_MODEL", "ENGRAM_GRPC_API_KEY", "ENGRAM_GRPC_BIND_ADDRESS", "ENGRAM_GRPC_PORT", "ENGRAM_HTTP_API_KEY", "ENGRAM_HTTP_BIND_ADDRESS", "ENGRAM_HTTP_PORT", "ENGRAM_HTTP_RATE_LIMIT_BURST", "ENGRAM_HTTP_RATE_LIMIT_KEY", "ENGRAM_HTTP_RATE_LIMIT_RPS", "ENGRAM_MEDIA_PUBLIC_DOMAIN", "ENGRAM_ONNX_MODEL_DIR", "ENGRAM_S3_BUCKET", "ENGRAM_STORAGE_URI", "ENGRAM_TOOL_TIER", "ENGRAM_WS_AUTH_KEY", "ENGRAM_WS_BIND_ADDRESS", "ENGRAM_WS_PORT", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "MEILISEARCH_API_KEY", "MEILISEARCH_INDEXER", "MEILISEARCH_SYNC_INTERVAL", "MEILISEARCH_URL", "OPENAI_API_KEY", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "VISION_PROVIDER"] +env_vars = ["AWS_ENDPOINT_URL", "AWS_PROFILE", "ENGRAM_CLEANUP_INTERVAL", "ENGRAM_CLOUD_ENCRYPT", "ENGRAM_CORS_ORIGINS", "ENGRAM_DB_PATH", "ENGRAM_EMBEDDING_MODEL", "ENGRAM_GRPC_API_KEY", "ENGRAM_GRPC_BIND_ADDRESS", "ENGRAM_GRPC_PORT", "ENGRAM_HTTP_API_KEY", "ENGRAM_HTTP_BIND_ADDRESS", "ENGRAM_HTTP_MAX_BODY_BYTES", "ENGRAM_HTTP_PORT", "ENGRAM_HTTP_RATE_LIMIT_BURST", "ENGRAM_HTTP_RATE_LIMIT_KEY", "ENGRAM_HTTP_RATE_LIMIT_RPS", "ENGRAM_HTTP_REQUEST_TIMEOUT_MS", "ENGRAM_HTTP_TRUSTED_PROXIES", "ENGRAM_MEDIA_PUBLIC_DOMAIN", "ENGRAM_ONNX_MODEL_DIR", "ENGRAM_S3_BUCKET", "ENGRAM_STORAGE_URI", "ENGRAM_TOOL_TIER", "ENGRAM_WS_ALLOWED_ORIGINS", "ENGRAM_WS_AUTH_KEY", "ENGRAM_WS_BIND_ADDRESS", "ENGRAM_WS_MAX_CONNECTIONS", "ENGRAM_WS_MAX_MESSAGE_BYTES", "ENGRAM_WS_PORT", "ENGRAM_WS_READ_IDLE_TIMEOUT_SECONDS", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "MEILISEARCH_API_KEY", "MEILISEARCH_INDEXER", "MEILISEARCH_SYNC_INTERVAL", "MEILISEARCH_URL", "OPENAI_API_KEY", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "VISION_PROVIDER"] feature_promises = ["agent-portability", "attestation", "clip-embeddings", "cloud", "cohere", "compression", "default", "dream-phase", "duckdb-graph", "emergent-graph", "full", "grpc", "langfuse", "local-embeddings", "meilisearch", "multimodal", "ollama", "onnx-embed", "openai", "pdf", "snapshot", "voyage", "watcher"] tool_tier_values = ["advanced", "all", "essential", "standard"] cli_examples = ["engram-cli --version", "engram-cli /usr/local/bin/", "engram-cli create \"Architecture decision: SQLite is the local source of truth\"", "engram-cli create \"Architecture: microservices with event sourcing\"", "engram-cli create \"Deploy to staging before production\" --type decision --tags \"deploy,process\"", "engram-cli create \"Deploy to staging before production\" \\", "engram-cli create \"The API uses JWT tokens for authentication\" --type note", "engram-cli graph --format json --output graph.json", "engram-cli model download minilm-l6-v2", "engram-cli search \"asynch awiat rust\"", "engram-cli search \"authentcation\"", "engram-cli search \"authentication\"", "engram-cli search \"staging deploy\""] diff --git a/docs/harness/.sensors-last b/docs/harness/.sensors-last index 9bab43d4..3248ac44 100644 --- a/docs/harness/.sensors-last +++ b/docs/harness/.sensors-last @@ -2,6 +2,6 @@ status=pass ci_status=pass doctor_status=pass mode=full -timestamp=2026-07-12T17:04:23Z -duration_sec=40 +timestamp=2026-07-13T23:20:58Z +duration_sec=32 ci=fmt + clippy + test_lib + test_integration + test_integration_watch + wasm_target + wasm_all_targets + wasm_wasm_target + doc + ref_check + pr-title-policy + harness doctor diff --git a/docs/harness/canvas/2026-07-13-engram-10-of-10-todo23.md b/docs/harness/canvas/2026-07-13-engram-10-of-10-todo23.md new file mode 100644 index 00000000..b07c0fa6 --- /dev/null +++ b/docs/harness/canvas/2026-07-13-engram-10-of-10-todo23.md @@ -0,0 +1,50 @@ +# Review Canvas: engram-10-of-10-todo23 + +Date: 2026-07-13 +Owner: TypeScript SDK lane +Scope: Verify the packed TypeScript SDK against a real authenticated Engram HTTP server without changing its public API. + +## Trigger + +| Trigger | Evidence | +|---|---| +| SDK public contract | The test drives the installed package's public create/list/get/search/update/delete methods. | +| Real process boundary | A packaged consumer talks to a real `engram-server` HTTP listener and disposable SQLite database. | + +## Approaches Considered + +| Approach | Decision | Reason | +|---|---|---| +| Reuse mocked `fetch` unit tests | Rejected | Mocks cannot prove the packed SDK matches the server's MCP envelope and authentication behavior. | +| Import the workspace `src/index.ts` directly | Rejected | That would bypass `dist`, package contents, and consumer module resolution. | +| Pack, install, compile the typed contract in a temporary consumer, and run it against a real server | Accepted | This proves the package boundary while remaining deterministic and local. | + +## Hot Path Complexity + +| Path | Time impact | Space impact | Notes | +|---|---|---|---| +| SDK request path | No production change | No production change | The lane adds verification only. | +| Live runner | One local build plus seven HTTP calls | One disposable package install and database | Temporary state is removed by a cleanup trap. | + +## Edge Cases + +| Edge case | Verification plan | +|---|---| +| A wrong bearer reaches the real endpoint | `--self-test-wrong-bearer` requires a bounded `EngramError` with HTTP 401. | +| The configured base URL has no MCP endpoint | `--self-test-missing-endpoint` requires a bounded `EngramError` with HTTP 404. | +| Server startup fails or hangs | The runner polls health with a fixed retry bound and fails if the child exits. | +| A bearer appears in server output | Cleanup scans the captured log for the generated bearer and fails closed. | + +## Breakage Risk + +| Risk | Impact | Mitigation | Verification | +|---|---|---|---| +| Source-tree imports create a false green | Published tarball could be broken | Compile the copied test from a temp consumer where only the tarball is installed. | Happy-path live runner. | +| MCP result envelope drifts | Public calls appear successful but payloads are unreadable | Parse and assert the real text/JSON tool envelope for every CRUD/search operation. | Happy-path live runner. | +| Negative tests leave a server running | CI hangs or leaks state | EXIT/INT/TERM cleanup trap terminates the child and removes the temp root. | Both negative modes exit cleanly. | + +## Decision + +Proceed: + +Keep the change test-only and package-boundary-focused. Public SDK behavior, registry publication, external services, and the shared CI workflow remain out of scope. diff --git a/docs/harness/progress.md b/docs/harness/progress.md index decbd549..978d70aa 100644 --- a/docs/harness/progress.md +++ b/docs/harness/progress.md @@ -7,8 +7,8 @@ | Active task | `engram-10-of-10-live-state — make harness live state truthful and self-checking` | | Active plan | `docs/harness/progress/2026-06-27-harness-live-state-closeout.md` | | Last review | `2026-07-10 — pass: docs/harness/reviews/2026-07-10-engram-10-of-10-live-state-v4-post.md` | -| Last sensors | `2026-07-12 — status=pass (mode=full; timestamp 2026-07-12T17:04:23Z)` | -| Last commit | `962428a` | +| Last sensors | `2026-07-13 — status=pass (mode=full; timestamp 2026-07-13T23:20:58Z)` | +| Last commit | `a0404587d8f552edc5ea649da1e2206dc17802c5` | | Last live-state check | `2026-07-13 — status=pass (rtk bash docs/harness/bin/check-live-state.sh --progress docs/harness/progress.md)` | > Sumário curto do trabalho ativo. Logs detalhados em `progress/`. @@ -85,6 +85,32 @@ commit SHAs while retaining version comments; the same `p/ci --error` scan reports zero findings without ignores or exclusions. +## Engram 10/10 Wave 4 — integrated implementation + +- **Integration HEAD**: `e17544af44dbfff8ec9b1351fdc19b90a2fdfc02` on + `feat/engram-10-of-10-wave4` (Todos 22–24, 26–27, 37–39 plus closeout + docs and PDF packaging-contract alignment). +- Todo 24 had two consecutive independent-review FAILs; both findings were + remediated in `6a42f008c012428396257a66c0cb830ae2d4cd88`. Per invariant 11 the + human owner responded `autorizado`, scoped only to accepting/integrating that + remediated SHA — not publication approval for any channel. +- First release dry-run `29271674475` failed closed before publication because + the binary smoke assumed `engram-server --version`. Remediation `2cfab45` + uses server `--help`, CLI `--version`, and a bounded empty-input PDF-worker + protocol smoke. +- Superseding dry-run `29286930522` completed **success** on exact SHA + `2cfab4563d5da43932c1cc3aa6741eeea6b487ea`: four-target builds, checksums, + SBOMs, signatures/provenance, and native artifact smoke passed. GitHub + Release and Homebrew jobs were **skipped**. No tag, registry, release, + Homebrew, or deploy write occurred. No claim that v0.22.0 or any SDK is + published. +- Independent Wave 4 review: `docs/harness/reviews/2026-07-13-engram-10-of-10-wave4-post.md` (`REVIEW_VERDICT: PASS`). +- Closeout then aligned `scripts/check-pdf-worker-packaging.py` with the Wave 4 + release workflow so `make ci` fails closed only on real packaging regressions. +- Atomic descriptor-bound SQLite opening remains deferred (stock VFS could not + satisfy race freedom, pool compatibility, cleanup, and locking across + Linux/macOS/BSD without a native shim or audited VFS). + - Tasks 11–16 landed as `bbd49fc` (HTTP fail-closed), `fc37fff` (gRPC security), `73f4959` (WebSocket authentication), `bc05e81` (durable cloud key identity), `815d1af` (SQLite permissions), and `ce292ac` (bounded PDF diff --git a/docs/harness/progress/2026-06-27-harness-live-state-closeout.md b/docs/harness/progress/2026-06-27-harness-live-state-closeout.md index aaae1798..3772693e 100644 --- a/docs/harness/progress/2026-06-27-harness-live-state-closeout.md +++ b/docs/harness/progress/2026-06-27-harness-live-state-closeout.md @@ -274,3 +274,35 @@ evidence file under `.omo/evidence/`. references across nightly, release, AgentShield loop, and harness-contract workflows. Pinned each reference to its resolved commit SHA with a version comment. The exact container scan now reports zero findings. + +## 2026-07-13 — Wave 4 integration and release dry-run remediation + +- Integrated Todos 22–24, 26–27, and 37–39 after focused verification and + independent review; the human owner explicitly accepted the remediated Todo + 24 SHA (`6a42f00…`) after its required two-FAIL escalation. Scope was + acceptance/integration only — not channel publication. +- Dry-run run `29271674475` matched the pre-remediation candidate and failed + closed because the artifact smoke invoked unsupported `engram-server --version`. +- Aligned the smoke with shipped contracts in `2cfab45`: server `--help`, CLI + `--version`, and a bounded PDF-worker protocol response. +- Superseding workflow-dispatch dry-run `29286930522` + (`https://github.com/aiconnai/engram/actions/runs/29286930522`) completed + success on exact SHA `2cfab4563d5da43932c1cc3aa6741eeea6b487ea`. Core + multi-target build, aggregate artifact verification, checksums, SBOMs, + signatures/provenance, and native smoke passed. GitHub Release and Homebrew + jobs were skipped. Tag, registry, release, Homebrew, and deploy writes: none. +- No claim that v0.22.0 or any SDK package is published. +- Atomic descriptor-bound SQLite opening remains deferred. +- Canonical live-state `Last commit` was first refreshed to `2cfab45…` during + Wave 4 closeout, then advanced again after packaging-contract alignment. + +## 2026-07-13 — Wave 4 PDF packaging contract alignment + +- `make ci` failed closed because `scripts/check-pdf-worker-packaging.py` still + required the pre-Wave-4 release.yml step names and inline tar packaging. +- Aligned the checker and unit suite with the attested release workflow: locked + multi-binary build, `verify-release-artifacts.sh` / + `test-release-binary.sh` Linux-only worker packaging, and the Homebrew formula + updater step. No publication path was opened. +- Live-state `Last commit` is now `e17544af44dbfff8ec9b1351fdc19b90a2fdfc02`. + Dry-run receipt `29286930522` remains bound to historical SHA `2cfab45…`. diff --git a/docs/harness/reviews/2026-07-13-engram-10-of-10-wave4-post.md b/docs/harness/reviews/2026-07-13-engram-10-of-10-wave4-post.md new file mode 100644 index 00000000..380a4ed3 --- /dev/null +++ b/docs/harness/reviews/2026-07-13-engram-10-of-10-wave4-post.md @@ -0,0 +1,102 @@ +REVIEW_VERDICT: PASS Wave 4 integration meets the security, SDK live-contract, quality-budget, and dry-run publication guards without fake success or publication claims + +## Scope + +- **Reviewer role**: independent; did not implement Wave 4. +- **Range**: `origin/main..HEAD` on `feat/engram-10-of-10-wave4` +- **Base main**: `962655a90f9bb05497fcc54fd78952c87ce19712` +- **Evidence consulted**: + - `.omo/start-work/SESSION_HANDOFF_2026-07-13_WAVE4.md` + - `.omo/evidence/task-{22,23,24,26,27,37,38,39}-engram-10-of-10.md` + - `docs/harness/progress.md` Wave 4 section + - `docs/harness/progress/2026-06-27-harness-live-state-closeout.md` + - `docs/harness/CODE_REVIEW_POLICY.md`, `docs/harness/GATES.md` +- **Spot-checked surfaces**: realtime WS server/config/events, HTTP transport router/security, quality-budget checker, release/SDK workflows and verify scripts, Python/TS live drivers, progress truthfulness. + +## Checklist + +### 1. WebSocket workspace / Origin isolation and filter-before-serialize — PASS + +- Handshake binds one normalized workspace (`/ws?workspace=…`, default `default`) and rejects unauthorized workspace before upgrade (`403`). +- Browser Origin: exact allowlist via `ENGRAM_WS_ALLOWED_ORIGINS`; wildcard/`*`, malformed, non-HTTP(S), userinfo, path/query, and multi-Origin fail closed; missing Origin remains non-browser path. +- Send path gates on `client_matches_event` **before** `serde_json::to_string` / write (`event.workspace() == Some(client.workspace)` plus subscription filter). +- Memory create/update/delete producers attach authoritative `data.workspace`. +- Regression: `real_client_receives_only_its_authorized_workspace` proves private sentinel never serializes to a default-workspace peer. + +### 2. WebSocket caps, idle timeout, bounded writes, cleanup, no-PII metrics — PASS + +- Env bounds: connections default 128 / hard max 10k; message bytes default 64 KiB / hard max 16 MiB; idle default 60s / hard max 3600s; zero/malformed/over-max fail closed at startup (`RealtimeConfig::from_env` → `Result`). +- Cap+1: semaphore `try_acquire_owned` → HTTP `503` before upgrade. +- Oversized frames: transport limit + app check → close `1009`; idle → close `1008`. +- Writes/close timeout-bounded (`WS_WRITE_TIMEOUT` 10s, `WS_CLOSE_TIMEOUT` 1s). +- Drop guards release registration, semaphore permit, and active gauges on disconnect/cancel. +- `/health` resources snapshot is aggregate counters only (no principal, token, workspace, or message dimensions). + +### 3. HTTP timeout / auth / body ordering; no detached timed-out mutations — PASS + +- Tower order (outer → inner): `enforce_request_timeout` → `enforce_mcp_auth` → `DefaultBodyLimit` → handler. +- Auth runs before body collection; unauthenticated oversized body returns `401` (not `413`). +- Authenticated `limit+1` → `413` before JSON parse; slow partial body → `408` / JSON-RPC `-32008`. +- No `spawn_blocking` / detached handler work under `src/mcp/http_transport`. +- Real-binary regressions in `tests/http_transport_security.rs` cover body, auth-before-body, timeout cleanup, notification `202`, and SSE setup-vs-established stream semantics. +- Invalid HTTP limit env values fall back to documented 1 MiB / 30 s defaults (cannot select unbounded mode). + +### 4. Installed-wheel Python and packed-npm TypeScript live behavior and negatives — PASS + +- Python: build/install wheel into isolated venv; assert package path under venv not `sdks/python`; live CRUD/search/close/context-manager against real loopback authenticated server; wrong-bearer and killed-server negative modes in `scripts/test-python-sdk-live.sh` + `test_live_client.py`. +- TypeScript: `npm pack` → install tarball in temp consumer; import only from packed package; happy + wrong-bearer + missing-endpoint; CI workflow runs all three modes. +- Receipts and local logs record green happy and negative paths; no registry publish in these lanes. + +### 5. Quality-budget resistance to editing all apparent baselines together — PASS (with residual) + +- Checker binds Criterion `before_criterion` to `git show :`. +- Self-test multiplies before/after/hot_path baseline copies and requires fail with immutable-source error. +- Retrieval floors require after == floors == observed and after ≥ before; CI required job runs checker with `fetch-depth: 0`. +- Residual (non-blocking): retrieval floor reduction is not git-object-bound the way Criterion is; simultaneous edit of floors + retrieval artifact + before/after metrics could still lower floors under process review. Criterion all-copy attack (review-2 FAIL root cause) is closed. + +### 6. Release / SDK workflow SHA / ref / category guards — PASS + +- Release preflight: full 40-char SHA must match `github.sha` and checked-out HEAD; live path requires exact version tag; dry-run rejects homebrew_only/overwrite write modes. +- SDK preflight: dispatch-only; dry-run forbids publish/tag; publish requires single channel, signed annotated `v0.22.0` tag, tag/SHA agreement, and existing immutable GitHub Release; publish jobs revalidate after environment approval. + +### 7. Artifact digest, SBOM, Sigstore/OIDC, provenance binding — PASS + +- Bundle creates SHA-256, CycloneDX 1.5, SPDX 2.3, SLSA provenance, internal signed attestation, and GitHub `attest-build-provenance` (OIDC) bundle. +- Offline verify binds archive digest, SBOM digests, provenance subject/SHA/target, attestation schema, and (when trusted) `gh attestation verify` against repo `aiconnai/engram`, signer workflow `.github/workflows/release.yml`, and `--source-digest`. + +### 8. Dry-run paths cannot reach publish jobs — PASS + +- Release: `release` and `update-homebrew` require `dry_run == false`; dry-run summary asserts no publication. +- SDK: `publish-python` / `publish-npm` require `dry_run == false && publish == true && channel match`; dry-run forbids publish in preflight. + +### 9. No fake-success skip / ignore / allowlist / continue-on-error weakening — PASS + +- Wave 4 release/SDK/live workflows do not introduce PR-blocking `continue-on-error` or ignore allowlists for required gates. +- Existing benchmark-action `continue-on-error` remains non-PR-only; PR path still has `fail-on-alert` and the separate required quality-budget step. +- Quality checker and release verify self-tests fail closed on mismatch/tamper. + +### 10. Truthful progress and publication claims — PASS + +- Progress and live-state closeout: + - dry-run `29286930522` bound to exact SHA `2cfab4563d5da43932c1cc3aa6741eeea6b487ea`; + - Release/Homebrew skipped; no tag/registry/release/Homebrew/deploy write; + - **no** claim that v0.22.0 or any SDK is published; + - SQLite descriptor-bound opening still deferred; + - Todo 24 human `autorizado` scoped only to integrating remediated SHA `6a42f00…`, not publication; + - integration HEAD advanced to closeout `e17544a…` while dry-run remains historically bound to `2cfab45…`. + +## Findings + +- [LOW] No blocking issues found in the Wave 4 integration range for the required checklist. Security isolation, resource bounds, HTTP ordering, live SDK package contracts, quality-budget Criterion immutability, release/SDK guards, artifact attestation, dry-run non-publication, and progress truthfulness hold under code and receipt review. + +## Residual risks (non-blocking) + +1. **Retrieval floor multi-edit** — Criterion baselines are git-revision-bound; retrieval floors still rely on co-edited JSON floors + observed metrics + update metadata. Mitigated by required reviewer/rationale/evidence fields and human review, not by immutable object binding. +2. **Python live CI negatives** — happy path is CI-wired; wrong-bearer/killed-server modes exist in the driver and receipts but are not separate required workflow steps (TypeScript CI does wire negatives). +3. **SSE omit-workspace semantics** — WebSocket always binds one workspace; SSE still allows omit-workspace for principals that authorize `None` (including anonymous loopback → effectively unfiltered event types). Adjacent surface; not the Todo 37 WS contract. Consider fail-closed default workspace for anonymous SSE if multi-tenant SSE hardens later. +4. **SQLite descriptor-bound open** — still deferred; Wave 4 does not claim Wave 2 SQLite race closure. +5. **Publication** — dry-run success is not channel approval; Todos 36 / 52–55 still need fresh explicit write approvals. + +## Verdict rationale + +Wave 4 delivers the stated foundations (WS isolation + bounds, HTTP lifetime/body bounds, live SDK package contracts, quality floors in required CI, attested dry-runnable release and gated SDK lanes) without weakening gates or asserting publication. Independent review of code paths and durable receipts supports merge-readiness of the integration branch subject to normal CI, not release/publish authorization. diff --git a/docs/quality/budgets.json b/docs/quality/budgets.json new file mode 100644 index 00000000..caf633aa --- /dev/null +++ b/docs/quality/budgets.json @@ -0,0 +1,65 @@ +{ + "schema_version": "engram.quality-budgets.v1", + "retrieval": { + "baseline_path": "tests/fixtures/retrieval_quality/baseline.json", + "floors": { + "recall@10": 1.0, + "mrr": 1.0, + "ndcg@10": 1.0 + } + }, + "criterion": { + "maximum_regression_ratio": 1.15, + "baseline_source_path": "benches/results/benchmark_baseline.txt", + "hot_paths": { + "entity_extractor_new/default": { + "baseline_value": 4.9578, + "baseline_unit": "ms" + }, + "entity_extraction/extract_mixed": { + "baseline_value": 17.144, + "baseline_unit": "us" + } + } + }, + "baseline_update": { + "source_revision": "81be152c713230c082901899e6880579fcedabb3", + "reviewer": "Wave 3 independent post-review", + "rationale": "Promote the measured deterministic Todo 20 retrieval metrics and the existing Criterion evidence into reviewed local engineering floors.", + "evidence": [ + "tests/fixtures/retrieval_quality/baseline.json", + "benches/results/benchmark_baseline.txt", + "benches/results/benchmark_results.txt" + ], + "before_metrics": { + "recall@10": 1.0, + "mrr": 1.0, + "ndcg@10": 1.0 + }, + "after_metrics": { + "recall@10": 1.0, + "mrr": 1.0, + "ndcg@10": 1.0 + }, + "before_criterion": { + "entity_extractor_new/default": { + "baseline_value": 4.9578, + "baseline_unit": "ms" + }, + "entity_extraction/extract_mixed": { + "baseline_value": 17.144, + "baseline_unit": "us" + } + }, + "after_criterion": { + "entity_extractor_new/default": { + "baseline_value": 4.9578, + "baseline_unit": "ms" + }, + "entity_extraction/extract_mixed": { + "baseline_value": 17.144, + "baseline_unit": "us" + } + } + } +} diff --git a/docs/releases/SDK_PUBLISHING.md b/docs/releases/SDK_PUBLISHING.md new file mode 100644 index 00000000..a1f5f4e4 --- /dev/null +++ b/docs/releases/SDK_PUBLISHING.md @@ -0,0 +1,58 @@ +# SDK Publishing + +The Python and TypeScript SDKs version independently from Engram core. The +authoritative versions and tested core ranges are recorded in +[`channel-matrix.toml`](channel-matrix.toml). + +## Safe default + +`.github/workflows/sdk-release.yml` is dispatch-only and defaults to +`dry_run=true`. A dry-run builds and verifies the selected Python wheel/sdist +and/or npm tarball, uploads them as workflow artifacts, and has no registry +credentials or registry-write job in its execution graph. + +Use the exact candidate SHA when dispatching: + +```bash +RUN_ID="$(bash scripts/dispatch-and-wait-workflow.sh \ + --workflow sdk-release.yml \ + --ref "$(git branch --show-current)" \ + --field channel=all \ + --field dry_run=true \ + --field sha="$(git rev-parse HEAD)")" +bash scripts/verify-sdk-artifacts.sh --run-id "$RUN_ID" --live +``` + +The verifier checks package names, independently versioned package metadata, +core compatibility ranges, SHA binding, checksums, compiled npm entry points, +and clean installation of both packages. `--live` additionally runs the real +server SDK contracts. + +## Protected publication + +Publication is not implied by a core release or by a successful dry-run. It +requires all of the following immediately before a registry request: + +1. The immutable final `v0.22.0` GitHub Release exists and its annotated tag + passes cryptographic `git verify-tag` validation. +2. `tag`, `github.sha`, the supplied full SHA, and checked-out HEAD agree. +3. Exactly one channel (`python` or `npm`) is selected with `dry_run=false` + and `publish=true`. +4. A release owner supplies fresh channel-specific approval through the + protected `release-pypi` or `release-npm` GitHub environment. +5. The registry has been verified read-only for package-name ownership and + version availability by the release owner. + +PyPI uses GitHub OIDC trusted publishing. npm uses npm trusted publishing with +OIDC provenance. No long-lived registry token is accepted by the workflow. + +After publication, verify from a clean consumer: + +```bash +bash scripts/verify-pypi-release.sh --package engram-client --version 0.5.0 +bash scripts/verify-npm-release.sh --package engram-client --version 0.5.0 +``` + +PyPI files and npm versions cannot be safely replaced. On an incident, yank or +deprecate the affected version as appropriate and publish a forward fix; never +silently rebuild an existing version. diff --git a/docs/releases/channel-matrix.toml b/docs/releases/channel-matrix.toml index 4aeddf88..b538ee66 100644 --- a/docs/releases/channel-matrix.toml +++ b/docs/releases/channel-matrix.toml @@ -1,5 +1,5 @@ [matrix] -schema_version = 1 +schema_version = 2 observed_utc = "2026-07-10T08:41:33Z" max_staleness_days = 30 repository = "aiconnai/engram" @@ -72,7 +72,10 @@ expected_status = "independent_lagging" observed_version = "0.4.0" owner = "Python SDK release owner" dry_run_command = "cd sdks/python && python3 -m build && twine check dist/*" -publish_command = "cd sdks/python && twine upload dist/*" +publish_command = "Protected sdk-release.yml python dispatch using PyPI trusted publishing only after exact tag/SHA approval." +workflow = ".github/workflows/sdk-release.yml" +approval_environment = "release-pypi" +trusted_publishing = "GitHub OIDC" rollback = "PyPI files are immutable; yank the release or publish a forward fix." human_gate = true @@ -86,7 +89,10 @@ expected_status = "independent_lagging" observed_version = "0.3.0" owner = "TypeScript SDK release owner" dry_run_command = "cd sdks/typescript && npm pack --dry-run" -publish_command = "cd sdks/typescript && npm publish --access public" +publish_command = "Protected sdk-release.yml npm dispatch using npm trusted publishing and provenance only after exact tag/SHA approval." +workflow = ".github/workflows/sdk-release.yml" +approval_environment = "release-npm" +trusted_publishing = "GitHub OIDC with npm provenance" rollback = "npm unpublish is time-limited and disruptive; prefer deprecate or forward patch." human_gate = true @@ -135,6 +141,7 @@ observed_registry_version = "0.4.0" compatible_core_min = "0.20.0" compatible_core_max = "0.22.x" compatibility_basis = "Async SDK public methods and MCP/HTTP JSON-RPC wrappers tested against the 0.20.x through 0.22.0 tool/API shape." +verification_command = "bash scripts/test-python-sdk-live.sh" publish_with_core = false [[sdk_compatibility]] @@ -145,4 +152,5 @@ observed_registry_version = "0.3.0" compatible_core_min = "0.20.0" compatible_core_max = "0.22.x" compatibility_basis = "Typed client wrappers target the stable MCP/HTTP JSON-RPC surface and may release independently from core binaries." +verification_command = "bash scripts/test-typescript-sdk-live.sh" publish_with_core = false diff --git a/docs/security/THREAT_MODEL.md b/docs/security/THREAT_MODEL.md index de108c75..70eb5919 100644 --- a/docs/security/THREAT_MODEL.md +++ b/docs/security/THREAT_MODEL.md @@ -90,7 +90,7 @@ Repository docs, generated plans, issue text, external references, and pasted ex | TM-STDIO | stdio MCP | stdio stays the default local MCP transport and does not bind a network socket. | Public bind is not applicable; do not translate local stdio trust into network trust. | Local process exits or client cannot connect. | Process logs only; no network auth signal expected. | Revert to stdio-only startup and remove network flags. | | TM-HTTP-MCP | HTTP MCP | HTTP is the primary network transport and defaults to `ENGRAM_HTTP_BIND_ADDRESS=127.0.0.1`. | Explicit non-loopback bind such as `0.0.0.0` requires ENGRAM_HTTP_API_KEY, Authorization: Bearer token, and deployment network controls; non-loopback without authentication is refused. | 401 Unauthorized for missing or wrong bearer token, 429 Too Many Requests for rate-limit exhaustion. | mcp_http_request with decision=unauthorized or decision=rate_limited, plus transport metrics. | Stop HTTP transport, remove the public bind, or revert to stdio-only. | | TM-SSE-EVENTS | SSE events | GET /v1/events shares the HTTP transport and is available only when realtime exists. | ENGRAM_HTTP_API_KEY Bearer auth is required for non-loopback, and realtime must be configured. | 401 Unauthorized for bad token; 503 Service Unavailable when realtime is absent. | events_requests_unauthorized_total, events_requests_no_realtime_total, and HTTP logs. | Disable realtime, stop HTTP, or remove /v1/events from public routing. | -| TM-WS-UPGRADES-EVENTS | WS upgrades/events | ENGRAM_WS_PORT is 0, so WS is opt-in and default-off; when enabled it defaults to `ENGRAM_WS_BIND_ADDRESS=127.0.0.1`. | Explicit non-loopback bind requires `ENGRAM_WS_AUTH_KEY` with `Authorization: Bearer` at the upgrade; a trusted proxy may terminate TLS but does not replace listener authentication, and loopback is not authentication. | 401 Unauthorized for missing, malformed, or wrong bearer tokens; a non-loopback listener without a key is refused before bind. | WS auth rejection reasons and server errors without token values, plus review evidence that no unauthenticated public bind exists. | Rollback by setting ENGRAM_WS_PORT=0 and removing the public WS listener. | +| TM-WS-UPGRADES-EVENTS | WS upgrades/events | ENGRAM_WS_PORT is 0, so WS is opt-in and default-off; when enabled it defaults to `ENGRAM_WS_BIND_ADDRESS=127.0.0.1`. | Explicit non-loopback bind requires `ENGRAM_WS_AUTH_KEY` with `Authorization: Bearer` at the upgrade; a trusted proxy may terminate TLS but does not replace listener authentication, and loopback is not authentication. Every subscription is authorized for one requested/default workspace. Browser handshakes with Origin require an exact `ENGRAM_WS_ALLOWED_ORIGINS` match; missing Origin is the supported non-browser contract. Connection, inbound byte, and read-idle limits are positive bounded values and invalid overrides fail startup closed. | 401 Unauthorized for missing, malformed, or wrong bearer tokens; 403 Forbidden for unauthorized workspace or disallowed Origin; 503 when the connection cap is full; WebSocket 1009 for oversized input; WebSocket 1008 for read-idle clients; a non-loopback listener without a key is refused before bind. | WS auth/workspace/Origin rejection reasons and aggregate connection/rejection/oversize/idle/cleanup counters without token, principal, workspace, or message values. | Rollback by setting ENGRAM_WS_PORT=0 and removing the public WS listener. | | TM-GRPC | feature-enabled gRPC | gRPC is supported when the grpc feature is enabled and defaults to `ENGRAM_GRPC_BIND_ADDRESS=127.0.0.1`. | Explicit non-loopback bind such as `0.0.0.0` requires ENGRAM_GRPC_API_KEY with Authorization: Bearer metadata. | UNAUTHENTICATED tonic status for missing or wrong token. | gRPC transport logs and unauthenticated status counts from deployment telemetry. | Disable the grpc feature, remove --transport grpc, or return to HTTP or stdio. | | TM-PROXY-IP | client IP/proxy headers | Rate-limit identity can use x-forwarded-for, x-real-ip, or an explicit key header. | trusted proxy rule: only a configured trusted proxy may author these headers; untrusted direct clients cannot set authoritative identity. | Rate-limit fallback to ip:unknown or rejection by proxy when identity is absent or spoofed. | rate-limit bucket keys, stale cleanup counts, and proxy audit logs. | Remove proxy-header keying and key only on direct connection metadata until trust is restored. | | TM-TOKEN-SCOPES | token scopes | Current HTTP and gRPC bearer tokens are process-wide, not scoped to workspace or tool. | Any future scope syntax must fail closed on unknown, expired, malformed, or overbroad scope claims; Bearer token rotation remains required. | 401 Unauthorized, UNAUTHENTICATED, or explicit release-blocking validation error. | Auth failures without token value logging; rotation receipt with redacted token names only. | Rotate the process-wide token, remove scoped-token rollout, and require fresh auth. | @@ -108,6 +108,11 @@ Repository docs, generated plans, issue text, external references, and pasted ex | ENGRAM_WS_PORT | TM-WS-UPGRADES-EVENTS | Opt-in/default-off; public use requires explicit non-loopback bind plus authenticated trusted proxy or first-party auth. | | ENGRAM_WS_BIND_ADDRESS | TM-WS-UPGRADES-EVENTS | Defaults to 127.0.0.1; public use requires explicit non-loopback bind plus authenticated trusted proxy or first-party auth. | | ENGRAM_WS_AUTH_KEY | TM-WS-UPGRADES-EVENTS | Configures the Bearer token required at WebSocket upgrade; token values must never appear in URLs or logs. | +| ENGRAM_WS_ALLOWED_ORIGINS | TM-WS-UPGRADES-EVENTS | Comma-separated exact `http`/`https` origins for browser handshakes; wildcard and malformed entries fail startup closed. | +| ENGRAM_WS_MAX_CONNECTIONS | TM-WS-UPGRADES-EVENTS | Positive simultaneous-connection cap; default 128 and invalid/zero overrides fail startup. | +| ENGRAM_WS_MAX_MESSAGE_BYTES | TM-WS-UPGRADES-EVENTS | Positive inbound frame/message byte cap; default 65536 and invalid/zero overrides fail startup. | +| ENGRAM_WS_READ_IDLE_TIMEOUT_SECONDS | TM-WS-UPGRADES-EVENTS | Positive post-handshake read-idle timeout; default 60 seconds and invalid/zero overrides fail startup. | +| GET /ws?workspace=name | TM-WS-UPGRADES-EVENTS | Selects one normalized workspace (default `default`) that the connection principal must be allowed to read; all other workspace events are filtered before serialization. | | GET /v1/events | TM-SSE-EVENTS | Event stream follows HTTP bearer auth and realtime availability. | ## 11. Compatibility and rollback diff --git a/scripts/check-candidate-receipts.py b/scripts/check-candidate-receipts.py new file mode 100755 index 00000000..5b6ec468 --- /dev/null +++ b/scripts/check-candidate-receipts.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Validate candidate and publication receipts without performing writes.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path + +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +TASK_RE = re.compile(r"^[0-9]+$") + + +class ReceiptError(ValueError): + pass + + +def parse_receipt(path: Path) -> dict[str, str]: + if not path.is_file(): + raise ReceiptError(f"receipt not found: {path}") + values: dict[str, str] = {} + for raw in path.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#") or ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip().lower().replace("-", "_") + value = value.strip().strip("`") + if key in values: + raise ReceiptError(f"duplicate field {key!r} in {path}") + values[key] = value + return values + + +def require_fields(values: dict[str, str], fields: set[str], path: Path) -> None: + missing = sorted(field for field in fields if not values.get(field)) + if missing: + raise ReceiptError(f"{path} missing fields: {', '.join(missing)}") + + +def validate_timestamp(value: str) -> None: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ReceiptError("timestamp must be RFC3339") from exc + if parsed.tzinfo is None: + raise ReceiptError("timestamp must include a timezone") + + +def validate_publication_receipt( + path: Path, *, task: str, decision: str, manifest: dict[str, object] | None +) -> dict[str, str]: + values = parse_receipt(path) + common = {"schema_version", "task", "channel", "decision", "timestamp"} + require_fields(values, common, path) + if values["schema_version"] != "engram-publication-receipt-v1": + raise ReceiptError(f"{path} has unsupported schema") + if values["task"] != task or values["decision"] != decision: + raise ReceiptError(f"{path} task/decision does not match its filename") + validate_timestamp(values["timestamp"]) + if decision == "approval": + require_fields(values, {"version", "sha", "exact_user_text"}, path) + if not SHA_RE.fullmatch(values["sha"]): + raise ReceiptError(f"{path} has invalid sha") + if manifest is not None: + manifest_sha = str(manifest.get("sha", "")) + if values["sha"] != manifest_sha: + raise ReceiptError(f"{path} approval SHA differs from manifest") + else: + require_fields(values, {"reason", "external_write"}, path) + if values["external_write"].lower() != "none": + raise ReceiptError(f"{path} deferral must record external_write: none") + return values + + +def publication_state( + evidence: Path, task: str, required: str | None, manifest_path: Path | None +) -> dict[str, str]: + if not TASK_RE.fullmatch(task): + raise ReceiptError("publication task must be numeric") + manifest = json.loads(manifest_path.read_text()) if manifest_path else None + approval = evidence / f"task-{task}-publication-approval.md" + deferral = evidence / f"task-{task}-publication-deferral.md" + present = [("approval", approval), ("deferral", deferral)] + present = [(kind, path) for kind, path in present if path.exists()] + if len(present) != 1: + raise ReceiptError(f"task {task} must have exactly one approval XOR deferral receipt") + decision, path = present[0] + if required and decision != required: + raise ReceiptError(f"task {task} requires {required}, found {decision}") + return validate_publication_receipt( + path, task=task, decision=decision, manifest=manifest + ) + + +def expand_tasks(spec: str) -> list[int]: + result: list[int] = [] + for item in filter(None, (part.strip() for part in spec.split(","))): + if "-" in item: + start_text, end_text = item.split("-", 1) + start, end = int(start_text), int(end_text) + if start > end: + raise ReceiptError(f"invalid descending range: {item}") + result.extend(range(start, end + 1)) + else: + result.append(int(item)) + if len(set(result)) != len(result): + raise ReceiptError("receipt task list contains duplicates") + return result + + +def git_output(*args: str) -> str: + return subprocess.check_output(["git", *args], text=True).strip() + + +def build_manifest(args: argparse.Namespace) -> None: + evidence = args.evidence_dir + approval_tasks = set(expand_tasks(args.publication_approvals or "")) + deferral_tasks = set(expand_tasks(args.publication_deferrals or "")) + overlap = approval_tasks & deferral_tasks + if overlap: + raise ReceiptError( + "publication tasks cannot be declared as both approval and deferral: " + + ", ".join(str(task) for task in sorted(overlap)) + ) + publication_tasks = approval_tasks | deferral_tasks + receipts: list[dict[str, object]] = [] + for task in expand_tasks(args.receipts): + if task in publication_tasks: + required_decision = "approval" if task in approval_tasks else "deferral" + state = publication_state( + evidence, str(task), required_decision, None + ) + if state["decision"] == "deferral" and not args.allow_deferred: + raise ReceiptError(f"task {task} is deferred but --allow-deferred is absent") + candidates = [ + evidence / f"task-{task}-publication-{state['decision']}.md" + ] + else: + candidates = [evidence / f"task-{task}-engram-10-of-10.md"] + for path in candidates: + if not path.is_file(): + raise ReceiptError(f"receipt not found: {path}") + receipts.append( + { + "task": task, + "path": str(path), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + ) + cargo_version = "unknown" + cargo = Path("Cargo.toml") + if cargo.is_file(): + match = re.search(r'^version\s*=\s*"([^"]+)"', cargo.read_text(), re.M) + if match: + cargo_version = match.group(1) + output = { + "schema_version": "engram-candidate-manifest-v1", + "sha": git_output("rev-parse", "HEAD"), + "base_sha": git_output("merge-base", "HEAD", "origin/main"), + "candidate_ref": git_output("branch", "--show-current"), + "version": cargo_version, + "receipts": receipts, + "statement": "THIS RECEIPT DOES NOT AUTHORIZE PUBLICATION", + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(output, indent=2, sort_keys=True) + "\n") + + +def write_receipt(path: Path, task: str, decision: str, sha: str = "0" * 40) -> None: + fields = [ + "schema_version: engram-publication-receipt-v1", + f"task: {task}", + "channel: GitHub", + f"decision: {decision}", + "timestamp: 2026-07-13T00:00:00Z", + ] + if decision == "approval": + fields += ["version: v0.22.0", f"sha: {sha}", "exact_user_text: approved"] + else: + fields += ["reason: approval not granted", "external_write: none"] + path.write_text("\n".join(fields) + "\n") + + +def self_test(mode: str) -> None: + with tempfile.TemporaryDirectory() as raw: + evidence = Path(raw) + manifest = evidence / "manifest.json" + manifest.write_text(json.dumps({"sha": "0" * 40}) + "\n") + if mode == "approval": + write_receipt(evidence / "task-36-publication-approval.md", "36", "approval") + state = publication_state(evidence, "36", "approval", manifest) + assert state["decision"] == "approval" + elif mode == "deferral": + write_receipt(evidence / "task-36-publication-deferral.md", "36", "deferral") + state = publication_state(evidence, "36", "deferral", manifest) + assert state["external_write"] == "none" + else: + write_receipt(evidence / "task-36-publication-approval.md", "36", "approval") + write_receipt(evidence / "task-36-publication-deferral.md", "36", "deferral") + try: + publication_state(evidence, "36", None, manifest) + except ReceiptError: + pass + else: + raise AssertionError("conflicting publication state was accepted") + (evidence / "task-36-publication-approval.md").unlink() + try: + publication_state(evidence, "36", "approval", manifest) + except ReceiptError: + pass + else: + raise AssertionError("deferral was accepted as an approval") + print(f"check-candidate-receipts {mode} self-test: PASS") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence-dir", type=Path, default=Path(".omo/evidence")) + parser.add_argument("--self-test", action="store_true") + parser.add_argument("--self-test-publication-deferral", action="store_true") + parser.add_argument("--self-test-conflicting-publication-state", action="store_true") + parser.add_argument("--publication-state") + parser.add_argument("--require", choices=("approval", "deferral")) + parser.add_argument("--manifest", type=Path) + parser.add_argument("--final", action="store_true") + parser.add_argument("--receipts") + parser.add_argument("--publication-approvals") + parser.add_argument("--publication-deferrals") + parser.add_argument("--allow-deferred", action="store_true") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + try: + modes = sum( + bool(value) + for value in ( + args.self_test, + args.self_test_publication_deferral, + args.self_test_conflicting_publication_state, + args.publication_state, + args.final, + ) + ) + if modes != 1: + raise ReceiptError("select exactly one validation mode") + if args.self_test: + self_test("approval") + elif args.self_test_publication_deferral: + self_test("deferral") + elif args.self_test_conflicting_publication_state: + self_test("conflict") + elif args.publication_state: + publication_state( + args.evidence_dir, args.publication_state, args.require, args.manifest + ) + print("publication receipt: PASS") + else: + if not args.receipts or not args.output: + raise ReceiptError("--final requires --receipts and --output") + build_manifest(args) + print(f"candidate manifest written: {args.output}") + except (ReceiptError, OSError, json.JSONDecodeError, subprocess.CalledProcessError) as exc: + print(f"check-candidate-receipts: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-pdf-worker-packaging.py b/scripts/check-pdf-worker-packaging.py index 31bb4350..0b3635ff 100644 --- a/scripts/check-pdf-worker-packaging.py +++ b/scripts/check-pdf-worker-packaging.py @@ -6,11 +6,25 @@ from pathlib import Path -RELEASE_BUILD = "cargo build --release --target ${{ matrix.target }} --features pdf --bin engram-server --bin engram-cli --bin engram-pdf-worker" +# CI still builds without --locked; the release workflow requires a locked graph. +CI_RELEASE_BUILD = ( + "cargo build --release --target ${{ matrix.target }} --features pdf " + "--bin engram-server --bin engram-cli --bin engram-pdf-worker" +) +RELEASE_BUILD = ( + "cargo build --locked --release --target ${{ matrix.target }} --features pdf " + "--bin engram-server --bin engram-cli --bin engram-pdf-worker" +) +# Back-compat alias used by unit fixtures that still refer to RELEASE_BUILD. +# Prefer the explicit CI_RELEASE_BUILD / RELEASE_BUILD names for new checks. RELEASE_ARCHIVE = 'tar -czf "../../../${ARCHIVE}" engram-server engram-cli engram-pdf-worker' CI_WORKER_PATH = "target/${{ matrix.target }}/release/engram-pdf-worker" HOMEBREW_UPDATE_COMMAND = "python3 ../engram-source/.github/scripts/ensure-pdf-worker-homebrew.py Formula/engram.rb" -VERSION_GUARD = 'if [[ ! "$RELEASE_VERSION" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then' +VERSION_GUARD = 'if [[ ! "${INPUT_VERSION}" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then' +CREATE_BUNDLE = "scripts/verify-release-artifacts.sh \\" +SMOKE_BINARIES = "scripts/test-release-binary.sh \\" +LINUX_ONLY_WORKER_SHELL = '[[ "$target" == *-linux-* ]] && binaries+=(engram-pdf-worker)' +LINUX_ONLY_WORKER_SMOKE = "[[ \"$target\" == *-linux-* ]] && expected+=(engram-pdf-worker)" def read_contract(root: Path, relative: str, missing: list[str]) -> str | None: @@ -95,35 +109,64 @@ def require_step_lines( missing.append(f"{relative}: step {name}: {required}") +def require_script_contains(missing: list[str], relative: str, text: str | None, needle: str) -> None: + if text is None: + return + if needle not in text: + missing.append(f"{relative}: missing required contract line: {needle}") + + def missing_contracts(root: Path) -> list[str]: missing: list[str] = [] release_path = ".github/workflows/release.yml" release = read_contract(root, release_path, missing) if release is not None: - require_step_lines(missing, release_path, release, "Validate release version", (f" {VERSION_GUARD}",)) - require_step_lines(missing, release_path, release, "Build release binaries", (f" run: {RELEASE_BUILD}",)) - require_step_lines(missing, release_path, release, "Package (Unix)", ( - " if [[ \"${{ matrix.target }}\" == *-linux-* ]]; then", - f" {RELEASE_ARCHIVE}", - ' tar -czf "../../../${ARCHIVE}" engram-server engram-cli', - )) require_step_lines( missing, release_path, release, - "Update formula", + "Validate event, ref, and SHA before any write", + (f" {VERSION_GUARD}",), + ) + require_step_lines( + missing, + release_path, + release, + "Build release binaries", + (f" run: {RELEASE_BUILD}",), + ) + require_step_lines( + missing, + release_path, + release, + "Package deterministic archive and supply-chain evidence", ( - f" {HOMEBREW_UPDATE_COMMAND}", - " if grep -F 'bin.install \"engram-pdf-worker\"' Formula/engram.rb >/dev/null; then", - ' echo "macOS formula must not install unsupported PDF worker" >&2', + f" {CREATE_BUNDLE}", + f" {SMOKE_BINARIES}", ), - " if: steps.homebrew-token.outputs.available == 'true'", ) + require_step_lines( + missing, + release_path, + release, + "Update the formula from verified checksums", + (f" {HOMEBREW_UPDATE_COMMAND}",), + ) + + verifier_path = "scripts/verify-release-artifacts.sh" + verifier = read_contract(root, verifier_path, missing) + require_script_contains(missing, verifier_path, verifier, LINUX_ONLY_WORKER_SHELL) + require_script_contains(missing, verifier_path, verifier, "engram-pdf-worker") + + smoke_path = "scripts/test-release-binary.sh" + smoke = read_contract(root, smoke_path, missing) + require_script_contains(missing, smoke_path, smoke, LINUX_ONLY_WORKER_SMOKE) + require_script_contains(missing, smoke_path, smoke, "engram-pdf-worker)") ci_path = ".github/workflows/ci.yml" ci = read_contract(root, ci_path, missing) if ci is not None: - require_step_lines(missing, ci_path, ci, "Build release", (f" run: {RELEASE_BUILD}",)) + require_step_lines(missing, ci_path, ci, "Build release", (f" run: {CI_RELEASE_BUILD}",)) require_step_lines( missing, ci_path, diff --git a/scripts/check-quality-budgets.py b/scripts/check-quality-budgets.py new file mode 100755 index 00000000..2a42b10f --- /dev/null +++ b/scripts/check-quality-budgets.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 +"""Enforce deterministic retrieval floors and portable Criterion budgets.""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, Mapping, Optional, Sequence, Tuple + + +METRICS = ("recall@10", "mrr", "ndcg@10") +UNITS_TO_SECONDS = { + "ns": 1e-9, + "us": 1e-6, + "µs": 1e-6, + "ms": 1e-3, + "s": 1.0, +} +TIME_RE = re.compile( + r"^\s*time:\s+\[[0-9.eE+-]+\s+(?:ns|us|µs|ms|s)\s+" + r"([0-9.eE+-]+)\s+(ns|us|µs|ms|s)\s+" + r"[0-9.eE+-]+\s+(?:ns|us|µs|ms|s)\]" +) + + +class BudgetError(ValueError): + """A named quality-budget contract violation.""" + + +def load_json(path: Path, label: str) -> Dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise BudgetError(f"{label}: cannot load {path}: {exc}") from exc + if not isinstance(value, dict): + raise BudgetError(f"{label}: root must be an object") + return value + + +def require_object(parent: Mapping[str, Any], key: str, label: str) -> Dict[str, Any]: + value = parent.get(key) + if not isinstance(value, dict): + raise BudgetError(f"{label}.{key}: must be an object") + return value + + +def require_metric_map(parent: Mapping[str, Any], key: str, label: str) -> Dict[str, float]: + raw = require_object(parent, key, label) + if set(raw) != set(METRICS): + raise BudgetError(f"{label}.{key}: must contain exactly {', '.join(METRICS)}") + metrics: Dict[str, float] = {} + for metric in METRICS: + value = raw[metric] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise BudgetError(f"{label}.{key}.{metric}: must be numeric") + number = float(value) + if not math.isfinite(number) or not 0.0 <= number <= 1.0: + raise BudgetError(f"{label}.{key}.{metric}: must be within [0, 1]") + metrics[metric] = number + return metrics + + +def validate_update( + budgets: Mapping[str, Any], + floors: Mapping[str, float], + observed: Mapping[str, float], + hot_paths: Mapping[str, Any], + prior_criterion: Mapping[str, float], +) -> None: + update = require_object(budgets, "baseline_update", "budgets") + revision = update.get("source_revision") + if not isinstance(revision, str) or re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise BudgetError("budgets.baseline_update.source_revision: must be a 40-character Git SHA") + for key in ("reviewer", "rationale"): + value = update.get(key) + if not isinstance(value, str) or not value.strip(): + raise BudgetError(f"budgets.baseline_update.{key}: must be non-empty") + evidence = update.get("evidence") + if not isinstance(evidence, list) or not evidence or not all( + isinstance(item, str) and item.strip() for item in evidence + ): + raise BudgetError("budgets.baseline_update.evidence: must contain reviewed evidence paths") + before = require_metric_map(update, "before_metrics", "budgets.baseline_update") + after = require_metric_map(update, "after_metrics", "budgets.baseline_update") + for metric in METRICS: + if not math.isclose(after[metric], floors[metric], rel_tol=0.0, abs_tol=1e-12): + raise BudgetError( + f"budgets.baseline_update.after_metrics.{metric}: must equal the committed floor" + ) + if not math.isclose(after[metric], observed[metric], rel_tol=0.0, abs_tol=1e-12): + raise BudgetError( + f"budgets.baseline_update.after_metrics.{metric}: must equal the measured retrieval value" + ) + if after[metric] < before[metric]: + raise BudgetError( + f"budget floor reduction: {metric} {before[metric]} -> {after[metric]}" + ) + + before_criterion = require_object( + update, "before_criterion", "budgets.baseline_update" + ) + after_criterion = require_object( + update, "after_criterion", "budgets.baseline_update" + ) + expected_names = set(hot_paths) + for key, values in ( + ("before_criterion", before_criterion), + ("after_criterion", after_criterion), + ): + if set(values) != expected_names: + raise BudgetError( + f"budgets.baseline_update.{key}: must contain exactly the named hot paths" + ) + for name in sorted(expected_names): + committed_seconds = parse_budget_timing(hot_paths[name], f"criterion baseline: {name}") + before_seconds = parse_budget_timing( + before_criterion[name], f"baseline update before_criterion: {name}" + ) + after_seconds = parse_budget_timing( + after_criterion[name], f"baseline update after_criterion: {name}" + ) + if not math.isclose( + after_seconds, committed_seconds, rel_tol=0.0, abs_tol=1e-18 + ): + raise BudgetError( + f"baseline update after_criterion: {name} must equal the committed baseline" + ) + prior_seconds = prior_criterion.get(name) + if prior_seconds is None or not math.isclose( + before_seconds, prior_seconds, rel_tol=0.0, abs_tol=1e-18 + ): + raise BudgetError( + f"baseline update before_criterion: {name} must equal immutable source-revision evidence" + ) + if after_seconds > before_seconds: + raise BudgetError( + f"criterion budget relaxation: {name} {before_seconds} -> {after_seconds} seconds" + ) + + +def parse_budget_timing(raw: Any, label: str) -> float: + if not isinstance(raw, dict): + raise BudgetError(f"{label}: must be an object") + value = raw.get("baseline_value") + unit = raw.get("baseline_unit") + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise BudgetError(f"{label}: must have a numeric baseline_value") + number = float(value) + if not math.isfinite(number) or number <= 0: + raise BudgetError(f"{label}: baseline_value must be finite and positive") + if unit not in UNITS_TO_SECONDS: + raise BudgetError(f"{label}: unsupported unit {unit!r}") + return number * UNITS_TO_SECONDS[unit] + + +def parse_criterion(text: str, names: Sequence[str]) -> Dict[str, Tuple[float, str]]: + lines = text.splitlines() + parsed: Dict[str, Tuple[float, str]] = {} + wanted = set(names) + for index, line in enumerate(lines): + name = line.strip() + if name not in wanted: + continue + for candidate in lines[index + 1 : index + 6]: + match = TIME_RE.match(candidate) + if match is not None: + value = float(match.group(1)) + if not math.isfinite(value) or value <= 0: + raise BudgetError( + f"criterion: {name} median must be finite and strictly positive" + ) + parsed[name] = (value, match.group(2)) + break + missing = sorted(wanted - set(parsed)) + if missing: + raise BudgetError(f"criterion: missing named hot path(s): {', '.join(missing)}") + return parsed + + +def load_prior_criterion(budgets: Mapping[str, Any]) -> Dict[str, float]: + update = require_object(budgets, "baseline_update", "budgets") + revision = update.get("source_revision") + criterion = require_object(budgets, "criterion", "budgets") + source_path = criterion.get("baseline_source_path") + if not isinstance(revision, str) or re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise BudgetError("budgets.baseline_update.source_revision: must be a 40-character Git SHA") + if not isinstance(source_path, str) or not source_path.strip(): + raise BudgetError("budgets.criterion.baseline_source_path: must be non-empty") + try: + completed = subprocess.run( + ["git", "show", f"{revision}:{source_path}"], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise BudgetError( + "criterion immutable baseline: cannot read source_revision evidence; " + "fetch full Git history before running the checker" + ) from exc + + hot_paths = require_object(criterion, "hot_paths", "budgets.criterion") + parsed: Dict[str, float] = {} + for line in completed.stdout.splitlines(): + name, separator, measurement = line.rpartition(": ") + if not separator or name not in hot_paths: + continue + parts = measurement.split() + if len(parts) != 2 or parts[1] not in UNITS_TO_SECONDS: + continue + try: + value = float(parts[0]) + except ValueError: + continue + if not math.isfinite(value) or value <= 0: + raise BudgetError( + f"criterion immutable baseline: {name} must be finite and positive" + ) + parsed[name] = value * UNITS_TO_SECONDS[parts[1]] + missing = sorted(set(hot_paths) - set(parsed)) + if missing: + raise BudgetError( + "criterion immutable baseline: missing named hot path(s): " + ", ".join(missing) + ) + return parsed + + +def validate( + budgets: Mapping[str, Any], + retrieval: Mapping[str, Any], + criterion_text: str, + prior_criterion: Mapping[str, float], +) -> Dict[str, Any]: + if budgets.get("schema_version") != "engram.quality-budgets.v1": + raise BudgetError("budgets.schema_version: expected engram.quality-budgets.v1") + if retrieval.get("schema_version") != "engram.quality-baseline.v1": + raise BudgetError("retrieval.schema_version: expected engram.quality-baseline.v1") + update = require_object(budgets, "baseline_update", "budgets") + if update.get("source_revision") != retrieval.get("source_revision"): + raise BudgetError( + "budgets.baseline_update.source_revision: must match the frozen retrieval source revision" + ) + + retrieval_policy = require_object(budgets, "retrieval", "budgets") + floors = require_metric_map(retrieval_policy, "floors", "budgets.retrieval") + observed = require_metric_map(retrieval, "metrics", "retrieval") + + retrieval_report = [] + for metric in METRICS: + if observed[metric] < floors[metric]: + raise BudgetError( + f"retrieval floor: {metric} observed {observed[metric]} below {floors[metric]}" + ) + retrieval_report.append( + {"metric": metric, "floor": floors[metric], "observed": observed[metric]} + ) + criterion = require_object(budgets, "criterion", "budgets") + maximum = criterion.get("maximum_regression_ratio") + if isinstance(maximum, bool) or not isinstance(maximum, (int, float)) or maximum != 1.15: + raise BudgetError("budgets.criterion.maximum_regression_ratio: expected 1.15") + hot_paths = require_object(criterion, "hot_paths", "budgets.criterion") + if not hot_paths: + raise BudgetError("budgets.criterion.hot_paths: must not be empty") + validate_update(budgets, floors, observed, hot_paths, prior_criterion) + parsed = parse_criterion(criterion_text, list(hot_paths)) + + performance_report = [] + for name, raw_baseline in hot_paths.items(): + observed_value, observed_unit = parsed[name] + baseline_seconds = parse_budget_timing(raw_baseline, f"criterion baseline: {name}") + observed_seconds = observed_value * UNITS_TO_SECONDS[observed_unit] + ratio = observed_seconds / baseline_seconds + if ratio > float(maximum) + 1e-12: + raise BudgetError( + f"criterion regression: {name} ratio {ratio:.4f} exceeds {float(maximum):.2f}" + ) + performance_report.append( + { + "path": name, + "baseline_seconds": baseline_seconds, + "observed_seconds": observed_seconds, + "ratio": ratio, + "maximum_ratio": float(maximum), + } + ) + + return { + "schema_version": "engram.quality-budget-report.v1", + "status": "pass", + "retrieval": retrieval_report, + "criterion": performance_report, + } + + +def run_self_test( + budgets: Mapping[str, Any], + retrieval: Mapping[str, Any], + criterion_text: str, + prior_criterion: Mapping[str, float], +) -> Dict[str, Any]: + validate(budgets, retrieval, criterion_text, prior_criterion) + degraded_retrieval = json.loads(json.dumps(retrieval)) + degraded_retrieval["metrics"]["mrr"] = max( + 0.0, float(budgets["retrieval"]["floors"]["mrr"]) - 0.01 + ) + try: + validate(budgets, degraded_retrieval, criterion_text, prior_criterion) + except BudgetError as exc: + retrieval_error = str(exc) + if "retrieval floor: mrr" not in retrieval_error: + raise BudgetError(f"self-test degraded retrieval returned wrong error: {exc}") from exc + else: + raise BudgetError("self-test degraded retrieval unexpectedly passed") + + hot_paths = budgets["criterion"]["hot_paths"] + name = next(iter(hot_paths)) + baseline = hot_paths[name] + seconds = ( + float(baseline["baseline_value"]) + * UNITS_TO_SECONDS[baseline["baseline_unit"]] + * 1.16 + ) + synthetic = f"{name}\n time: [{seconds} s {seconds} s {seconds} s]\n" + for other_name, other in list(hot_paths.items())[1:]: + other_seconds = float(other["baseline_value"]) * UNITS_TO_SECONDS[other["baseline_unit"]] + synthetic += ( + f"{other_name}\n" + f" time: [{other_seconds} s {other_seconds} s {other_seconds} s]\n" + ) + try: + validate(budgets, retrieval, synthetic, prior_criterion) + except BudgetError as exc: + criterion_error = str(exc) + if f"criterion regression: {name}" not in criterion_error: + raise BudgetError(f"self-test 116% regression returned wrong error: {exc}") from exc + else: + raise BudgetError("self-test 116% regression unexpectedly passed") + + invalid = "".join( + f"{path}\n time: [-1 s -1 s -1 s]\n" + for path in hot_paths + ) + try: + validate(budgets, retrieval, invalid, prior_criterion) + except BudgetError as exc: + invalid_timing_error = str(exc) + if "strictly positive" not in invalid_timing_error: + raise BudgetError(f"self-test invalid timing returned wrong error: {exc}") from exc + else: + raise BudgetError("self-test negative Criterion timing unexpectedly passed") + + relaxed_budgets = json.loads(json.dumps(budgets)) + relaxed_name = next(iter(hot_paths)) + relaxed_budgets["criterion"]["hot_paths"][relaxed_name]["baseline_value"] *= 1000 + relaxed_budgets["baseline_update"]["after_criterion"][relaxed_name][ + "baseline_value" + ] *= 1000 + relaxed_budgets["baseline_update"]["before_criterion"][relaxed_name][ + "baseline_value" + ] *= 1000 + try: + validate(relaxed_budgets, retrieval, criterion_text, prior_criterion) + except BudgetError as exc: + relaxation_error = str(exc) + if ( + f"baseline update before_criterion: {relaxed_name} must equal immutable" + not in relaxation_error + ): + raise BudgetError(f"self-test budget relaxation returned wrong error: {exc}") from exc + else: + raise BudgetError("self-test Criterion budget relaxation unexpectedly passed") + + return { + "schema_version": "engram.quality-budget-self-test.v1", + "status": "pass", + "cases": [ + {"case": "retrieval_metric_decrement", "blocked_by": retrieval_error}, + {"case": "criterion_116_percent", "blocked_by": criterion_error}, + {"case": "criterion_negative_timing", "blocked_by": invalid_timing_error}, + {"case": "criterion_budget_relaxation", "blocked_by": relaxation_error}, + ], + } + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--budgets", type=Path, required=True) + parser.add_argument("--retrieval", type=Path, required=True) + parser.add_argument("--criterion", type=Path, required=True) + parser.add_argument("--self-test-degraded", action="store_true") + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + try: + budgets = load_json(args.budgets, "budgets") + retrieval = load_json(args.retrieval, "retrieval") + retrieval_policy = require_object(budgets, "retrieval", "budgets") + configured_path = retrieval_policy.get("baseline_path") + if configured_path != args.retrieval.as_posix(): + raise BudgetError( + "budgets.retrieval.baseline_path: must match the checked retrieval artifact" + ) + update = require_object(budgets, "baseline_update", "budgets") + evidence = update.get("evidence") + if isinstance(evidence, list): + missing_evidence = [ + item + for item in evidence + if isinstance(item, str) and not Path(item).is_file() + ] + if missing_evidence: + raise BudgetError( + "budgets.baseline_update.evidence: missing path(s): " + + ", ".join(missing_evidence) + ) + criterion_text = args.criterion.read_text(encoding="utf-8") + prior_criterion = load_prior_criterion(budgets) + if args.self_test_degraded: + report = run_self_test(budgets, retrieval, criterion_text, prior_criterion) + else: + report = validate(budgets, retrieval, criterion_text, prior_criterion) + except (BudgetError, OSError) as exc: + print(json.dumps({"status": "fail", "error": str(exc)}, sort_keys=True)) + return 1 + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dispatch-and-wait-workflow.sh b/scripts/dispatch-and-wait-workflow.sh new file mode 100755 index 00000000..0ae10357 --- /dev/null +++ b/scripts/dispatch-and-wait-workflow.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { echo "dispatch-and-wait-workflow: $*" >&2; exit 1; } + +assert_sha() { + local expected=$1 actual=$2 + [[ "$expected" =~ ^[0-9a-f]{40}$ ]] || die "expected SHA must be a full lowercase SHA" + [[ "$actual" == "$expected" ]] || die "workflow head SHA mismatch" +} + +self_test() { + assert_sha "$(printf 'a%.0s' {1..40})" "$(printf 'a%.0s' {1..40})" + echo "dispatch-and-wait-workflow self-test: PASS" +} + +self_test_sha_mismatch() { + if (assert_sha "$(printf 'a%.0s' {1..40})" "$(printf 'b%.0s' {1..40})") >/dev/null 2>&1; then + die "SHA mismatch self-test accepted a stale run" + fi + echo "dispatch-and-wait-workflow SHA mismatch self-test: PASS" +} + +workflow='' +ref='' +mode=dispatch +timeout=1800 +fields=() +while [[ $# -gt 0 ]]; do + case "$1" in + --workflow) workflow=${2:-}; mode=dispatch; shift 2 ;; + --existing-workflow) workflow=${2:-}; mode=existing; shift 2 ;; + --ref) ref=${2:-}; shift 2 ;; + --field) fields+=("${2:-}"); shift 2 ;; + --timeout) timeout=${2:-}; shift 2 ;; + --self-test) self_test; exit 0 ;; + --self-test-sha-mismatch) self_test_sha_mismatch; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ -n "$workflow" && -n "$ref" ]] || die "--workflow/--existing-workflow and --ref are required" +[[ "$timeout" =~ ^[1-9][0-9]*$ ]] || die "timeout must be positive seconds" +command -v gh >/dev/null || die "gh is required" + +expected_sha= +for field in "${fields[@]}"; do + [[ "$field" == *=* ]] || die "--field must be key=value" + if [[ "$field" == sha=* ]]; then expected_sha=${field#sha=}; fi +done +if [[ -z "$expected_sha" ]]; then + expected_sha="$(git rev-parse "${ref}^{commit}")" +fi +assert_sha "$expected_sha" "$expected_sha" + +before="$(mktemp)" +trap 'rm -f "$before"' EXIT +gh run list --workflow "$workflow" --limit 100 --json databaseId --jq '.[].databaseId' > "$before" +if [[ "$mode" == dispatch ]]; then + command=(gh workflow run "$workflow" --ref "$ref") + for field in "${fields[@]}"; do command+=(-f "$field"); done + "${command[@]}" >&2 +fi + +deadline=$((SECONDS + timeout)) +run_id= +while (( SECONDS < deadline )); do + runs="$(gh run list --workflow "$workflow" --limit 100 --json databaseId,headSha,headBranch,event,status,conclusion)" + run_id="$(RUNS="$runs" BEFORE="$before" EXPECTED_SHA="$expected_sha" MODE="$mode" python3 - <<'PY' +import json, os +before = set(open(os.environ["BEFORE"]).read().split()) +for run in json.loads(os.environ["RUNS"]): + if run["headSha"] != os.environ["EXPECTED_SHA"]: + continue + if os.environ["MODE"] == "dispatch" and str(run["databaseId"]) in before: + continue + print(run["databaseId"]) + break +PY +)" + [[ -n "$run_id" ]] && break + sleep 3 +done +[[ -n "$run_id" ]] || die "timed out waiting for workflow run" + +actual_sha="$(gh run view "$run_id" --json headSha --jq .headSha)" +assert_sha "$expected_sha" "$actual_sha" +gh run watch "$run_id" --exit-status >&2 +conclusion="$(gh run view "$run_id" --json conclusion --jq .conclusion)" +[[ "$conclusion" == success ]] || die "workflow concluded: $conclusion" +printf '%s\n' "$run_id" diff --git a/scripts/qa-product-workflows.sh b/scripts/qa-product-workflows.sh new file mode 100755 index 00000000..28c35efd --- /dev/null +++ b/scripts/qa-product-workflows.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" +die() { echo "qa-product-workflows: $*" >&2; exit 1; } + +validate_workflow() { + python3 -c ' +import pathlib,re,sys +text=pathlib.Path(sys.argv[1]).read_text() +for job,environment in (("publish-python","release-pypi"),("publish-npm","release-npm")): + start=text.find(" "+job+":") + if start<0: raise SystemExit("missing "+job) + match=re.search(r"\n [a-z][a-z0-9-]+:\n",text[start+3:]); end=-1 if match is None else start+3+match.start(); block=text[start:] if end<0 else text[start:end] + if "environment: "+environment not in block: raise SystemExit(job+" lacks protected environment") + if "needs.preflight.outputs.dry_run == '\''false'\''" not in block or "needs.preflight.outputs.publish == '\''true'\''" not in block: raise SystemExit(job+" lacks fail-closed condition") + if "git verify-tag" not in block: raise SystemExit(job+" lacks signed-tag revalidation") +' "$1" +} + +if [[ "${1:-}" == "--self-test" ]]; then + workflow="$repo_root/.github/workflows/sdk-release.yml" + [[ -f "$workflow" ]] || die "SDK release workflow is missing" + validate_workflow "$workflow" + grep -Fq 'supplied SHA does not match github.sha' "$workflow" || die "SHA guard is missing" + grep -Fq 'tag, github.sha, and supplied SHA do not agree' "$workflow" || die "tag/SHA guard is missing" + grep -Fq 'Dry-run complete: no registry request was attempted.' "$workflow" || die "dry-run terminal assertion is missing" + tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT + grep -v 'environment: release-pypi' "$workflow" >"$tmp" + set +e + validate_workflow "$tmp" >/dev/null 2>&1 + status=$? + set -e + [[ $status -ne 0 ]] || die "missing-approval fixture was accepted" + echo "qa-product-workflows self-test: PASS" + exit 0 +fi + +if [[ "${1:-}" == "--self-test-negative" ]]; then + shift + artifact_dir="" + while [[ $# -gt 0 ]]; do + case "$1" in --artifact-dir) artifact_dir=${2:-}; shift 2 ;; *) die "unknown argument: $1" ;; esac + done + [[ -d "$artifact_dir" ]] || die "--artifact-dir is required" + tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT + while IFS= read -r metadata; do + cp "$metadata" "$tmp/$(basename "$metadata")" + while IFS= read -r package; do ln -s "$package" "$tmp/$(basename "$package")"; done < <(find "$(dirname "$metadata")" -maxdepth 1 -type f \( -name '*.whl' -o -name '*.tar.gz' -o -name '*.tgz' \)) + done < <(find "$artifact_dir" -type f -name '*-sdk-metadata.json') + python3 -c 'import json,pathlib,sys; p=next(pathlib.Path(sys.argv[1]).glob("python-sdk-metadata.json")); d=json.loads(p.read_text()); d["version"]="9.9.9"; p.write_text(json.dumps(d))' "$tmp" + set +e + "$repo_root/scripts/verify-sdk-artifacts.sh" --artifact-dir "$tmp" --expected-sha "$(git rev-parse HEAD)" >/dev/null 2>&1 + status=$? + set -e + [[ $status -ne 0 ]] || die "package-drift fixture was accepted" + + python3 scripts/check-quality-budgets.py \ + --budgets docs/quality/budgets.json \ + --retrieval tests/fixtures/retrieval_quality/baseline.json \ + --criterion benches/results/benchmark_results.txt \ + --self-test-degraded >/dev/null + cargo test --test retrieval_quality evaluator_reports_cross_workspace_leak -- --nocapture >/dev/null + "$repo_root/scripts/verify-sdk-artifacts.sh" \ + --artifact-dir "$artifact_dir" --expected-sha "$(git rev-parse HEAD)" --live + echo "qa-product-workflows negative self-test: PASS" + exit 0 +fi + +artifact_args=() +manifest="" +while [[ $# -gt 0 ]]; do + case "$1" in + --artifact-dir) artifact_args+=(--artifact-dir "${2:-}"); shift 2 ;; + --run-id) artifact_args+=(--run-id "${2:-}"); shift 2 ;; + --expected-sha) artifact_args+=(--expected-sha "${2:-}"); shift 2 ;; + --manifest) manifest=${2:-}; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ ${#artifact_args[@]} -gt 0 ]] || die "--artifact-dir or --run-id is required" +if [[ -n "$manifest" ]]; then + [[ -f "$manifest" ]] || die "manifest does not exist: $manifest" + manifest_sha="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["sha"])' "$manifest")" + artifact_args+=(--manifest "$manifest" --expected-sha "$manifest_sha") +fi +"$repo_root/scripts/verify-sdk-artifacts.sh" "${artifact_args[@]}" --live +bash "$repo_root/scripts/test-examples.sh" +cargo test --test retrieval_quality -- --nocapture +python3 "$repo_root/scripts/check-quality-budgets.py" \ + --budgets docs/quality/budgets.json \ + --retrieval tests/fixtures/retrieval_quality/baseline.json \ + --criterion benches/results/benchmark_results.txt +cargo test --test dream_eval_tests --features dream-phase -- --nocapture +echo "qa-product-workflows: PASS" diff --git a/scripts/sign-candidate-manifest.sh b/scripts/sign-candidate-manifest.sh new file mode 100755 index 00000000..25331edb --- /dev/null +++ b/scripts/sign-candidate-manifest.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + sign-candidate-manifest.sh --manifest FILE --signature FILE [--key PEM] + sign-candidate-manifest.sh --verify FILE --signature FILE + sign-candidate-manifest.sh --self-test + +The signature is a self-contained JSON envelope. When --key is omitted a new +ephemeral RSA key is used; once the envelope is committed, Git history binds +that exact public key and signature to the reviewed candidate. +EOF +} + +die() { echo "sign-candidate-manifest: $*" >&2; exit 1; } +sha256_file() { openssl dgst -sha256 -r "$1" | awk '{print $1}'; } + +sign_manifest() { + local manifest=$1 signature=$2 key=${3:-} + [[ -f "$manifest" ]] || die "manifest not found: $manifest" + command -v openssl >/dev/null || die "openssl is required" + command -v python3 >/dev/null || die "python3 is required" + + local tmp key_origin + tmp="$(mktemp -d)" + trap 'rm -rf "${tmp:-}"' RETURN + if [[ -z "$key" ]]; then + key="$tmp/private.pem" + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out "$key" >/dev/null 2>&1 + key_origin=ephemeral + else + [[ -f "$key" ]] || die "signing key not found: $key" + key_origin=provided + fi + openssl pkey -in "$key" -pubout -out "$tmp/public.pem" >/dev/null 2>&1 + openssl dgst -sha256 -sign "$key" -out "$tmp/signature.bin" "$manifest" + + MANIFEST_SHA="$(sha256_file "$manifest")" \ + PUBLIC_KEY="$tmp/public.pem" RAW_SIGNATURE="$tmp/signature.bin" \ + KEY_ORIGIN="$key_origin" OUTPUT="$signature" python3 - <<'PY' +import base64, json, os +from pathlib import Path + +envelope = { + "schema_version": "engram-candidate-signature-v1", + "algorithm": "rsa-pkcs1v15-sha256", + "manifest_sha256": os.environ["MANIFEST_SHA"], + "key_origin": os.environ["KEY_ORIGIN"], + "public_key_pem": Path(os.environ["PUBLIC_KEY"]).read_text(), + "signature_base64": base64.b64encode(Path(os.environ["RAW_SIGNATURE"]).read_bytes()).decode(), +} +Path(os.environ["OUTPUT"]).write_text(json.dumps(envelope, indent=2, sort_keys=True) + "\n") +PY + trap - RETURN + rm -rf "$tmp" + echo "signed $manifest -> $signature" +} + +verify_manifest() { + local manifest=$1 signature=$2 + [[ -f "$manifest" ]] || die "manifest not found: $manifest" + [[ -f "$signature" ]] || die "signature not found: $signature" + local tmp expected actual + tmp="$(mktemp -d)" + trap 'rm -rf "${tmp:-}"' RETURN + SIGNATURE_FILE="$signature" PUBLIC_KEY="$tmp/public.pem" RAW_SIGNATURE="$tmp/signature.bin" python3 - <<'PY' +import base64, json, os +from pathlib import Path + +data = json.loads(Path(os.environ["SIGNATURE_FILE"]).read_text()) +required = {"schema_version", "algorithm", "manifest_sha256", "public_key_pem", "signature_base64"} +if set(data) < required: + raise SystemExit("signature envelope is missing required fields") +if data["schema_version"] != "engram-candidate-signature-v1": + raise SystemExit("unsupported signature schema") +if data["algorithm"] != "rsa-pkcs1v15-sha256": + raise SystemExit("unsupported signature algorithm") +Path(os.environ["PUBLIC_KEY"]).write_text(data["public_key_pem"]) +Path(os.environ["RAW_SIGNATURE"]).write_bytes(base64.b64decode(data["signature_base64"], validate=True)) +print(data["manifest_sha256"]) +PY + expected="$(SIGNATURE_FILE="$signature" python3 -c 'import json,os; print(json.load(open(os.environ["SIGNATURE_FILE"]))["manifest_sha256"])')" + actual="$(sha256_file "$manifest")" + [[ "$actual" == "$expected" ]] || die "manifest digest mismatch" + openssl dgst -sha256 -verify "$tmp/public.pem" -signature "$tmp/signature.bin" "$manifest" >/dev/null \ + || die "cryptographic signature verification failed" + trap - RETURN + rm -rf "$tmp" + echo "verified $manifest" +} + +self_test() { + local tmp + tmp="$(mktemp -d)" + self_test_tmp=$tmp + trap 'rm -rf "${self_test_tmp:-}"' EXIT + printf '{"sha":"%040d","version":"0.22.0"}\n' 0 > "$tmp/manifest.json" + sign_manifest "$tmp/manifest.json" "$tmp/manifest.sig" + verify_manifest "$tmp/manifest.json" "$tmp/manifest.sig" + printf 'tamper\n' >> "$tmp/manifest.json" + if (verify_manifest "$tmp/manifest.json" "$tmp/manifest.sig") >/dev/null 2>&1; then + die "self-test accepted a tampered manifest" + fi + echo "sign-candidate-manifest self-test: PASS" +} + +manifest='' +signature='' +key='' +verify='' +case "${1:-}" in + --self-test) self_test; exit 0 ;; +esac +while [[ $# -gt 0 ]]; do + case "$1" in + --manifest) manifest=${2:-}; shift 2 ;; + --verify) verify=${2:-}; shift 2 ;; + --signature) signature=${2:-}; shift 2 ;; + --key) key=${2:-}; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ -n "$signature" ]] || die "--signature is required" +if [[ -n "$verify" ]]; then + [[ -z "$manifest" && -z "$key" ]] || die "--verify cannot be combined with --manifest/--key" + verify_manifest "$verify" "$signature" +else + [[ -n "$manifest" ]] || die "--manifest is required" + sign_manifest "$manifest" "$signature" "$key" +fi diff --git a/scripts/test-python-sdk-live.sh b/scripts/test-python-sdk-live.sh new file mode 100755 index 00000000..7bdcbb69 --- /dev/null +++ b/scripts/test-python-sdk-live.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +scenario="happy" +case "${1:-}" in + "") ;; + --self-test-wrong-bearer) scenario="wrong_bearer" ;; + --self-test-killed-server) scenario="killed_server" ;; + *) printf 'usage: %s [--self-test-wrong-bearer|--self-test-killed-server]\n' "$0" >&2; exit 2 ;; +esac + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/engram-python-sdk-live.XXXXXX")" +venv="$tmp/venv" +server_pid="" +port="" +token="" + +stop_server() { + if [[ -n "$server_pid" ]]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + server_pid="" + fi +} + +redact_log() { + if [[ -f "$tmp/server.log" ]]; then + sed "s/${token:-unused}//g" "$tmp/server.log" >&2 + fi +} + +cleanup() { + stop_server + rm -rf "$tmp" +} +trap cleanup EXIT INT TERM + +python3 -m venv "$venv" +python="$venv/bin/python" +"$python" -m pip install --quiet --upgrade pip +"$python" -m pip install --quiet build pytest pytest-asyncio + +mkdir -p "$tmp/dist" +cp -R sdks/python "$tmp/python-sdk-source" +( + cd "$tmp/python-sdk-source" + "$python" -m build --wheel --outdir "$tmp/dist" +) +wheel=("$tmp"/dist/*.whl) +if [[ ${#wheel[@]} -ne 1 || ! -f "${wheel[0]}" ]]; then + printf 'expected exactly one Python SDK wheel\n' >&2 + exit 1 +fi +"$python" -m pip install --quiet "${wheel[0]}" + +cargo build --quiet --bin engram-server +port="$(python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +token="$(python3 - <<'PY' +import secrets +print(secrets.token_hex(24)) +PY +)" + +ENGRAM_DB_PATH="$tmp/live.db" \ +ENGRAM_EMBEDDING_MODEL=tfidf \ +ENGRAM_HTTP_API_KEY="$token" \ +target/debug/engram-server \ + --transport http \ + --http-port "$port" \ + --embedding-model tfidf \ + --cleanup-interval-seconds 0 \ + --embedding-drain-interval-seconds 0 \ + --compression-interval-seconds 0 \ + --ws-port 0 >"$tmp/server.log" 2>&1 & +server_pid=$! + +ready=0 +for _ in $(seq 1 300); do + if "$python" - "$port" <<'PY' >/dev/null 2>&1 +import sys +import urllib.request +with urllib.request.urlopen(f"http://127.0.0.1:{sys.argv[1]}/health", timeout=0.2) as response: + assert response.status == 200 +PY + then + ready=1 + break + fi + kill -0 "$server_pid" 2>/dev/null || break + sleep 0.1 +done +if [[ $ready -ne 1 ]]; then + printf 'real Engram HTTP server did not become ready\n' >&2 + redact_log + exit 1 +fi + +client_token="$token" +if [[ "$scenario" == "wrong_bearer" ]]; then + client_token="${token}wrong" +elif [[ "$scenario" == "killed_server" ]]; then + stop_server +fi + +ENGRAM_LIVE_BASE_URL="http://127.0.0.1:$port" \ +ENGRAM_LIVE_API_KEY="$client_token" \ +ENGRAM_LIVE_TENANT="python-sdk-live" \ +ENGRAM_LIVE_VENV="$venv" \ +ENGRAM_LIVE_SCENARIO="$scenario" \ +"$python" -I -m pytest --import-mode=importlib sdks/python/tests/test_live_client.py -q + +stop_server +"$python" - "$port" <<'PY' +import socket +import sys +with socket.socket() as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", int(sys.argv[1]))) +PY + +printf 'Python SDK installed-wheel live contract passed (%s).\n' "$scenario" diff --git a/scripts/test-release-binary.sh b/scripts/test-release-binary.sh new file mode 100755 index 00000000..8ea03583 --- /dev/null +++ b/scripts/test-release-binary.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { echo "test-release-binary: $*" >&2; exit 1; } + +host_target() { + local os arch + os="$(uname -s)"; arch="$(uname -m)" + case "$os/$arch" in + Darwin/arm64) echo aarch64-apple-darwin ;; + Darwin/x86_64) echo x86_64-apple-darwin ;; + Linux/aarch64|Linux/arm64) echo aarch64-unknown-linux-gnu ;; + Linux/x86_64) echo x86_64-unknown-linux-gnu ;; + *) die "unsupported host: $os/$arch" ;; + esac +} + +artifact_dir='' +run_id='' +target='' +allow_cross=false +while [[ $# -gt 0 ]]; do + case "$1" in + --artifact-dir) artifact_dir=${2:-}; shift 2 ;; + --run-id) run_id=${2:-}; shift 2 ;; + --target) target=${2:-}; shift 2 ;; + --allow-cross) allow_cross=true; shift ;; + *) die "unknown argument: $1" ;; + esac +done +cleanup= +if [[ -n "$run_id" ]]; then + cleanup="$(mktemp -d)" + trap 'rm -rf "${cleanup:-}"' EXIT + gh run download "$run_id" --dir "$cleanup" + artifact_dir=$cleanup +fi +[[ -d "$artifact_dir" ]] || die "--artifact-dir or --run-id is required" +host="$(host_target)" +[[ -n "$target" ]] || target=$host +archives=() +while IFS= read -r path; do archives+=("$path"); done < <(find "$artifact_dir" -type f -name "engram-v*-${target}.tar.gz" | sort) +if [[ ${#archives[@]} -ne 1 ]]; then + die "expected one archive for $target, found ${#archives[@]}" +fi +if [[ "$target" != "$host" ]]; then + [[ "$allow_cross" == true ]] || die "cannot execute $target archive on $host" + echo "test-release-binary: cross target $target inspected but not executed" + exit 0 +fi +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp" "${cleanup:-}"' EXIT +tar -xzf "${archives[0]}" -C "$tmp" +expected=(engram-server engram-cli) +[[ "$target" == *-linux-* ]] && expected+=(engram-pdf-worker) +for binary in "${expected[@]}"; do + [[ -x "$tmp/$binary" ]] || die "missing executable: $binary" + case "$binary" in + engram-cli) + output="$("$tmp/$binary" --version 2>&1)" || die "$binary --version failed" + [[ -n "$output" ]] || die "$binary --version returned empty output" + ;; + engram-server) + output="$("$tmp/$binary" --help 2>&1)" || die "$binary --help failed" + [[ "$output" == *"Usage:"* ]] || die "$binary --help returned unexpected output" + ;; + engram-pdf-worker) + output="$("$tmp/$binary" --max-pages 1 --max-text-bytes 1024 &1)" \ + || die "$binary protocol smoke failed" + printf '%s' "$output" | python3 -c ' +import json, sys +response = json.load(sys.stdin) +if not isinstance(response.get("sections"), list) or "error" not in response: + raise SystemExit("unexpected PDF worker response") +' || die "$binary protocol smoke returned unexpected output" + ;; + esac +done +echo "test-release-binary: PASS ($target)" diff --git a/scripts/test-typescript-sdk-live.sh b/scripts/test-typescript-sdk-live.sh new file mode 100755 index 00000000..cad9a219 --- /dev/null +++ b/scripts/test-typescript-sdk-live.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +sdk_dir="${repo_root}/sdks/typescript" +mode="happy" + +case "${1:-}" in + "") ;; + --self-test-wrong-bearer) mode="wrong-bearer" ;; + --self-test-missing-endpoint) mode="missing-endpoint" ;; + *) + echo "usage: $0 [--self-test-wrong-bearer|--self-test-missing-endpoint]" >&2 + exit 2 + ;; +esac + +for command_name in cargo node npm python3; do + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "missing required command: ${command_name}" >&2 + exit 2 + fi +done + +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/engram-typescript-live.XXXXXX")" +server_pid="" +server_log="${tmp_root}/server.log" +api_key="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" + +cleanup() { + local status="$1" + trap - EXIT INT TERM + if [[ -n "${server_pid}" ]] && kill -0 "${server_pid}" 2>/dev/null; then + kill "${server_pid}" 2>/dev/null || true + wait "${server_pid}" 2>/dev/null || true + fi + if [[ -f "${server_log}" ]] && grep -Fq -- "${api_key}" "${server_log}"; then + echo "server log exposed the live bearer" >&2 + status=1 + fi + rm -rf "${tmp_root}" + exit "${status}" +} +trap 'cleanup $?' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +( + cd "${sdk_dir}" + npm ci --ignore-scripts --no-audit --no-fund + npm run type-check + npm test + npm run build + npm pack --dry-run >/dev/null + npm pack --pack-destination "${tmp_root}" >/dev/null +) + +( + cd "${repo_root}" + cargo build --bin engram-server +) + +port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" +db_path="${tmp_root}/engram.db" +ENGRAM_HTTP_API_KEY="${api_key}" \ +ENGRAM_TOOL_TIER=all \ +ENGRAM_EMBEDDING_MODEL=tfidf \ +ENGRAM_CLEANUP_INTERVAL=0 \ +ENGRAM_EMBEDDING_DRAIN_INTERVAL=0 \ + "${repo_root}/target/debug/engram-server" \ + --transport http \ + --http-bind-address 127.0.0.1 \ + --http-port "${port}" \ + --db-path "${db_path}" \ + >"${server_log}" 2>&1 & +server_pid=$! + +base_url="http://127.0.0.1:${port}" +ready=0 +for _ in $(seq 1 100); do + if ! kill -0 "${server_pid}" 2>/dev/null; then + echo "engram-server exited before becoming ready" >&2 + sed "s/${api_key}/[REDACTED]/g" "${server_log}" >&2 + exit 1 + fi + if node -e 'fetch(process.argv[1]).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))' "${base_url}/health"; then + ready=1 + break + fi + sleep 0.1 +done +if [[ "${ready}" -ne 1 ]]; then + echo "engram-server did not become ready" >&2 + exit 1 +fi + +consumer_dir="${tmp_root}/consumer" +mkdir -p "${consumer_dir}/test" +cp "${sdk_dir}/test/live-client.test.ts" "${consumer_dir}/test/live-client.test.ts" +tarball="$(find "${tmp_root}" -maxdepth 1 -name 'engram-client-*.tgz' -print -quit)" +if [[ -z "${tarball}" ]]; then + echo "npm pack did not produce an installable tarball" >&2 + exit 1 +fi + +( + cd "${consumer_dir}" + npm init --yes >/dev/null + npm pkg set type=module >/dev/null + npm install --ignore-scripts --no-audit --no-fund "${tarball}" + "${sdk_dir}/node_modules/.bin/tsc" \ + test/live-client.test.ts \ + --target ES2020 \ + --module NodeNext \ + --moduleResolution NodeNext \ + --lib ES2020,DOM \ + --strict \ + --skipLibCheck \ + --outDir compiled + + cat > run-live.mjs <<'EOF' +import { runLiveClientContract } from "./compiled/live-client.test.js"; + +await runLiveClientContract({ + baseUrl: process.env.ENGRAM_LIVE_BASE_URL, + apiKey: process.env.ENGRAM_LIVE_CLIENT_KEY, + tenant: "typescript-live", + mode: process.env.ENGRAM_LIVE_MODE, + marker: process.env.ENGRAM_LIVE_MARKER, +}); +EOF + + client_key="${api_key}" + client_url="${base_url}" + if [[ "${mode}" == "wrong-bearer" ]]; then + client_key="${api_key}-wrong" + elif [[ "${mode}" == "missing-endpoint" ]]; then + client_url="${base_url}/missing" + fi + + ENGRAM_LIVE_BASE_URL="${client_url}" \ + ENGRAM_LIVE_CLIENT_KEY="${client_key}" \ + ENGRAM_LIVE_MODE="${mode}" \ + ENGRAM_LIVE_MARKER="$(python3 -c 'import secrets; print(secrets.token_hex(8))')" \ + node run-live.mjs +) + +echo "TypeScript SDK packed live contract (${mode}): PASS" diff --git a/scripts/test_check_pdf_worker_packaging.py b/scripts/test_check_pdf_worker_packaging.py index a45619df..25ebc0d2 100644 --- a/scripts/test_check_pdf_worker_packaging.py +++ b/scripts/test_check_pdf_worker_packaging.py @@ -20,24 +20,22 @@ "jobs:", " build:", " steps:", - " - name: Validate release version", + " - name: Validate event, ref, and SHA before any write", " run: |", f" {MODULE.VERSION_GUARD}", " exit 1", " fi", " - name: Build release binaries", f" run: {MODULE.RELEASE_BUILD}", - " - name: Package (Unix)", + " - name: Package deterministic archive and supply-chain evidence", " run: |", - ' if [[ "${{ matrix.target }}" == *-linux-* ]]; then', - f" {MODULE.RELEASE_ARCHIVE}", - ' tar -czf "../../../${ARCHIVE}" engram-server engram-cli', - " - name: Update formula", - " if: steps.homebrew-token.outputs.available == 'true'", + f" {MODULE.CREATE_BUNDLE}", + " --create-bundle \\", + f" {MODULE.SMOKE_BINARIES}", + " --artifact-dir release-bundle \\", + " - name: Update the formula from verified checksums", " run: |", f" {MODULE.HOMEBREW_UPDATE_COMMAND}", - " if grep -F 'bin.install \"engram-pdf-worker\"' Formula/engram.rb >/dev/null; then", - ' echo "macOS formula must not install unsupported PDF worker" >&2', ) ) CI = "\n".join( @@ -46,7 +44,7 @@ " release:", " steps:", " - name: Build release", - f" run: {MODULE.RELEASE_BUILD}", + f" run: {MODULE.CI_RELEASE_BUILD}", " - name: Upload PDF worker artifact", " if: contains(matrix.target, '-linux-')", " with:", @@ -72,6 +70,21 @@ 'pdf_worker_docker_support = "not-advertised"', ) ) +VERIFY_SCRIPT = "\n".join( + ( + "binaries=(engram-server engram-cli)", + f"{MODULE.LINUX_ONLY_WORKER_SHELL}", + "archive_members+=(engram-pdf-worker)", + ) +) +SMOKE_SCRIPT = "\n".join( + ( + "expected=(engram-server engram-cli)", + f"{MODULE.LINUX_ONLY_WORKER_SMOKE}", + " engram-pdf-worker)", + " # protocol smoke", + ) +) class PdfWorkerPackagingContractTests(unittest.TestCase): @@ -83,6 +96,8 @@ def setUp(self) -> None: ".github/workflows/ci.yml": CI, "Cargo.toml": CARGO, "docs/contracts/advertised-surfaces.toml": INVENTORY, + "scripts/verify-release-artifacts.sh": VERIFY_SCRIPT, + "scripts/test-release-binary.sh": SMOKE_SCRIPT, }.items(): path = self.root / relative path.parent.mkdir(parents=True, exist_ok=True) @@ -120,32 +135,71 @@ def test_worker_build_in_unrelated_step_does_not_satisfy_release_step(self) -> N def test_echoed_worker_build_does_not_satisfy_release_step(self) -> None: release = self.root / ".github/workflows/release.yml" - release.write_text(release.read_text(encoding="utf-8").replace(f"run: {MODULE.RELEASE_BUILD}", f"run: echo '{MODULE.RELEASE_BUILD}'", 1), encoding="utf-8") + release.write_text( + release.read_text(encoding="utf-8").replace( + f"run: {MODULE.RELEASE_BUILD}", + f"run: echo '{MODULE.RELEASE_BUILD}'", + 1, + ), + encoding="utf-8", + ) self.assertTrue(any("Build release binaries" in item for item in MODULE.missing_contracts(self.root))) def test_disabled_worker_build_fails(self) -> None: release = self.root / ".github/workflows/release.yml" - release.write_text(release.read_text(encoding="utf-8").replace(" - name: Build release binaries\n", " - name: Build release binaries\n if: false\n", 1), encoding="utf-8") + release.write_text( + release.read_text(encoding="utf-8").replace( + " - name: Build release binaries\n", + " - name: Build release binaries\n if: false\n", + 1, + ), + encoding="utf-8", + ) self.assertTrue(any("must not be conditional" in item for item in MODULE.missing_contracts(self.root))) def test_continue_on_error_worker_build_fails(self) -> None: release = self.root / ".github/workflows/release.yml" - release.write_text(release.read_text(encoding="utf-8").replace(" - name: Build release binaries\n", " - name: Build release binaries\n continue-on-error: true\n", 1), encoding="utf-8") + release.write_text( + release.read_text(encoding="utf-8").replace( + " - name: Build release binaries\n", + " - name: Build release binaries\n continue-on-error: true\n", + 1, + ), + encoding="utf-8", + ) self.assertTrue(any("must fail closed" in item for item in MODULE.missing_contracts(self.root))) def test_permissive_release_version_guard_fails(self) -> None: release = self.root / ".github/workflows/release.yml" - release.write_text(release.read_text(encoding="utf-8").replace(MODULE.VERSION_GUARD, 'if [[ ! "$RELEASE_VERSION" =~ ^v.*$ ]]; then'), encoding="utf-8") - self.assertTrue(any("Validate release version" in item for item in MODULE.missing_contracts(self.root))) + release.write_text( + release.read_text(encoding="utf-8").replace( + MODULE.VERSION_GUARD, + 'if [[ ! "${INPUT_VERSION}" =~ ^v.*$ ]]; then', + ), + encoding="utf-8", + ) + self.assertTrue( + any("Validate event, ref, and SHA before any write" in item for item in MODULE.missing_contracts(self.root)) + ) def test_macos_worker_upload_fails(self) -> None: ci = self.root / ".github/workflows/ci.yml" - ci.write_text(ci.read_text(encoding="utf-8").replace(" if: contains(matrix.target, '-linux-')\n", ""), encoding="utf-8") + ci.write_text( + ci.read_text(encoding="utf-8").replace(" if: contains(matrix.target, '-linux-')\n", ""), + encoding="utf-8", + ) self.assertTrue(any("Upload PDF worker artifact" in item for item in MODULE.missing_contracts(self.root))) def test_worker_inventory_in_wrong_table_fails(self) -> None: inventory = self.root / "docs/contracts/advertised-surfaces.toml" - inventory.write_text(INVENTORY.replace('binaries = ["engram-server", "engram-cli", "engram-pdf-worker"]', 'binaries = ["engram-server", "engram-cli"]') + '\n[other]\nbinaries = ["engram-pdf-worker"]\n', encoding="utf-8") + inventory.write_text( + INVENTORY.replace( + 'binaries = ["engram-server", "engram-cli", "engram-pdf-worker"]', + 'binaries = ["engram-server", "engram-cli"]', + ) + + '\n[other]\nbinaries = ["engram-pdf-worker"]\n', + encoding="utf-8", + ) self.assertTrue(any("[cli].binaries" in item for item in MODULE.missing_contracts(self.root))) def test_missing_file_reports_contract_error(self) -> None: @@ -153,17 +207,27 @@ def test_missing_file_reports_contract_error(self) -> None: self.assertTrue(any("Cargo.toml: unreadable" in item for item in MODULE.missing_contracts(self.root))) def test_malformed_cargo_value_reports_contract_error(self) -> None: - (self.root / "Cargo.toml").write_text('[[bin]]\nname = [\n', encoding="utf-8") + (self.root / "Cargo.toml").write_text("[[bin]]\nname = [\n", encoding="utf-8") self.assertTrue(any("Cargo.toml: invalid contract value" in item for item in MODULE.missing_contracts(self.root))) def test_malformed_inventory_value_reports_contract_error(self) -> None: - (self.root / "docs/contracts/advertised-surfaces.toml").write_text('[cli]\nbinaries = [\n', encoding="utf-8") - self.assertTrue(any("advertised-surfaces.toml: invalid contract value" in item for item in MODULE.missing_contracts(self.root))) + (self.root / "docs/contracts/advertised-surfaces.toml").write_text("[cli]\nbinaries = [\n", encoding="utf-8") + self.assertTrue( + any("advertised-surfaces.toml: invalid contract value" in item for item in MODULE.missing_contracts(self.root)) + ) + + def test_missing_linux_only_packaging_rule_fails(self) -> None: + verifier = self.root / "scripts/verify-release-artifacts.sh" + verifier.write_text("binaries=(engram-server engram-cli engram-pdf-worker)\n", encoding="utf-8") + self.assertTrue(any("verify-release-artifacts.sh" in item for item in MODULE.missing_contracts(self.root))) def test_homebrew_updater_removes_worker(self) -> None: formula = self.root / "Formula/engram.rb" formula.parent.mkdir(parents=True, exist_ok=True) - formula.write_text('def install\n bin.install "engram-server"\n bin.install "engram-cli"\n bin.install "engram-pdf-worker"\nend\n', encoding="utf-8") + formula.write_text( + 'def install\n bin.install "engram-server"\n bin.install "engram-cli"\n bin.install "engram-pdf-worker"\nend\n', + encoding="utf-8", + ) updater = SCRIPT.parent.parent / ".github/scripts/ensure-pdf-worker-homebrew.py" subprocess.run(["python3", str(updater), str(formula)], check=True) self.assertNotIn('bin.install "engram-pdf-worker"', formula.read_text(encoding="utf-8")) diff --git a/scripts/verify-crates-release.sh b/scripts/verify-crates-release.sh new file mode 100755 index 00000000..50c9f7ed --- /dev/null +++ b/scripts/verify-crates-release.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { echo "verify-crates-release: $*" >&2; exit 1; } +sha256_file() { openssl dgst -sha256 -r "$1" | awk '{print $1}'; } + +validate_metadata() { + METADATA="$1" CRATE="$2" VERSION="$3" python3 - <<'PY' +import json, os +data=json.loads(os.environ["METADATA"]) +matches=[item for item in data.get("versions",[]) if item.get("num")==os.environ["VERSION"]] +if len(matches)!=1: + raise SystemExit("registry version is missing or ambiguous") +item=matches[0] +if item.get("yanked"): + raise SystemExit("registry version is yanked") +checksum=item.get("checksum","") +if len(checksum)!=64: + raise SystemExit("registry checksum is invalid") +print(checksum) +PY +} + +self_test() { + local json checksum + json='{"versions":[{"num":"0.22.0","yanked":false,"checksum":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}' + checksum="$(validate_metadata "$json" engram-core 0.22.0)" + [[ "$checksum" == "$(printf 'a%.0s' {1..64})" ]] || die "self-test checksum mismatch" + if validate_metadata "${json/false/true}" engram-core 0.22.0 >/dev/null 2>&1; then + die "self-test accepted a yanked crate" + fi + echo "verify-crates-release self-test: PASS" +} + +[[ "${1:-}" == --self-test ]] && { self_test; exit 0; } +crate='' +version='' +sha='' +while [[ $# -gt 0 ]]; do + case "$1" in + --crate) crate=${2:-}; shift 2 ;; + --version) version=${2:-}; shift 2 ;; + --sha) sha=${2:-}; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ "$crate" =~ ^[a-z0-9_-]+$ ]] || die "invalid crate name" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "invalid version" +[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || die "invalid SHA" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +metadata="$(curl --fail --silent --show-error "https://crates.io/api/v1/crates/${crate}")" +expected="$(validate_metadata "$metadata" "$crate" "$version")" +curl --fail --silent --show-error --location \ + "https://crates.io/api/v1/crates/${crate}/${version}/download" -o "$tmp/package.crate" +[[ "$(sha256_file "$tmp/package.crate")" == "$expected" ]] || die "crate checksum mismatch" +tar -xzf "$tmp/package.crate" -C "$tmp" +vcs_file="$(find "$tmp" -name .cargo_vcs_info.json -type f -print -quit)" +[[ -n "$vcs_file" ]] || die "crate lacks .cargo_vcs_info.json" +actual_sha="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["git"]["sha1"])' "$vcs_file")" +[[ "$actual_sha" == "$sha" ]] || die "crate source SHA mismatch" +mkdir "$tmp/consumer" +cat > "$tmp/consumer/Cargo.toml" < "$tmp/consumer/src/main.rs" +cargo generate-lockfile --manifest-path "$tmp/consumer/Cargo.toml" +cargo check --locked --manifest-path "$tmp/consumer/Cargo.toml" +echo "verify-crates-release: PASS ($crate $version)" diff --git a/scripts/verify-github-release.sh b/scripts/verify-github-release.sh new file mode 100755 index 00000000..4528aa69 --- /dev/null +++ b/scripts/verify-github-release.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +die() { echo "verify-github-release: $*" >&2; exit 1; } + +validate_release_json() { + RELEASE_JSON="$1" TAG="$2" python3 - <<'PY' +import json, os +data = json.loads(os.environ["RELEASE_JSON"]) +if data.get("tagName") != os.environ["TAG"]: + raise SystemExit("release tag mismatch") +if data.get("isDraft") or data.get("isPrerelease"): + raise SystemExit("release is not a final published release") +names = {asset["name"] for asset in data.get("assets", [])} +targets = ("aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu") +required = set() +for target in targets: + base = f"engram-{os.environ['TAG']}-{target}.tar.gz" + required.update({base, f"{base}.sha256", f"{base}.cyclonedx.json", f"{base}.spdx.json", f"{base}.provenance.json", f"{base}.attestation.json", f"{base}.attestation.sig", f"{base}.github-attestation.sigstore.json"}) +missing = required - names +if missing: + raise SystemExit("release assets missing: " + ", ".join(sorted(missing))) +PY +} + +self_test() { + local tag=v0.22.0 json + json="$(TAG="$tag" python3 - <<'PY' +import json, os +assets=[] +for target in ("aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"): + base=f"engram-{os.environ['TAG']}-{target}.tar.gz" + for suffix in ("", ".sha256", ".cyclonedx.json", ".spdx.json", ".provenance.json", ".attestation.json", ".attestation.sig", ".github-attestation.sigstore.json"): + assets.append({"name": base+suffix}) +print(json.dumps({"tagName":os.environ["TAG"],"isDraft":False,"isPrerelease":False,"assets":assets})) +PY +)" + validate_release_json "$json" "$tag" + if validate_release_json "${json/v0.22.0/v0.21.2}" "$tag" >/dev/null 2>&1; then + die "self-test accepted a different release tag" + fi + echo "verify-github-release self-test: PASS" +} + +[[ "${1:-}" == --self-test ]] && { self_test; exit 0; } +tag='' +sha='' +artifact_dir='' +repo=${GITHUB_REPOSITORY:-aiconnai/engram} +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) tag=${2:-}; shift 2 ;; + --sha) sha=${2:-}; shift 2 ;; + --artifact-dir) artifact_dir=${2:-}; shift 2 ;; + --repo) repo=${2:-}; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "invalid tag" +[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || die "invalid SHA" +command -v gh >/dev/null || die "gh is required" +commit_sha="$(gh api "repos/${repo}/commits/${tag}" --jq .sha)" +[[ "$commit_sha" == "$sha" ]] || die "release tag commit does not match approved SHA" +release_json="$(gh release view "$tag" --repo "$repo" --json tagName,isDraft,isPrerelease,assets)" +validate_release_json "$release_json" "$tag" +cleanup= +if [[ -z "$artifact_dir" ]]; then + cleanup="$(mktemp -d)"; trap 'rm -rf "${cleanup:-}"' EXIT + gh release download "$tag" --repo "$repo" --dir "$cleanup" + artifact_dir=$cleanup +fi +"$ROOT/scripts/verify-release-artifacts.sh" --artifact-dir "$artifact_dir" --expected-sha "$sha" +echo "verify-github-release: PASS ($tag)" diff --git a/scripts/verify-homebrew-release.sh b/scripts/verify-homebrew-release.sh new file mode 100755 index 00000000..e03b79f1 --- /dev/null +++ b/scripts/verify-homebrew-release.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +BREW_COMMIT=999134536e623073ea9b2a8954eeea5898137239 +die() { echo "verify-homebrew-release: $*" >&2; exit 1; } + +validate_version() { + [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "invalid stable version" +} + +run_isolated_command() { + local root status + root="$(mktemp -d)" + set +e + ENGRAM_DISPOSABLE_PREFIX="$root" HOME="$root/home" HOMEBREW_CACHE="$root/cache" "$@" + status=$? + set -e + rm -rf "$root" + [[ ! -e "$root" ]] || die "disposable prefix cleanup failed" + return "$status" +} + +prefix_probe() { + [[ -n "${ENGRAM_DISPOSABLE_PREFIX:-}" ]] || return 1 + [[ "$HOME" == "$ENGRAM_DISPOSABLE_PREFIX/home" ]] || return 1 + [[ "$HOMEBREW_CACHE" == "$ENGRAM_DISPOSABLE_PREFIX/cache" ]] || return 1 + mkdir -p "$HOME" "$HOMEBREW_CACHE" + printf '%s\n' "$ENGRAM_DISPOSABLE_PREFIX" > "$ENGRAM_DISPOSABLE_PREFIX/probe" +} + +self_test() { + validate_version 0.22.0 + if (validate_version '0.22.0;rm') >/dev/null 2>&1; then + die "self-test accepted unsafe version" + fi + echo "verify-homebrew-release self-test: PASS" +} + +self_test_prefix() { + run_isolated_command "$0" --prefix-probe + echo "verify-homebrew-release prefix isolation self-test: PASS" +} + +verify_isolated() { + local version=$1 prefix="$ENGRAM_DISPOSABLE_PREFIX/brew" + mkdir -p "$HOME" "$HOMEBREW_CACHE" + git clone --filter=blob:none https://github.com/Homebrew/brew.git "$prefix" + git -C "$prefix" checkout --detach "$BREW_COMMIT" + export HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_ANALYTICS=1 HOMEBREW_NO_ENV_HINTS=1 + "$prefix/bin/brew" tap aiconnai/engram + "$prefix/bin/brew" install "aiconnai/engram/engram@${version}" 2>/dev/null \ + || "$prefix/bin/brew" install aiconnai/engram/engram + output="$("$prefix/bin/engram-server" --version 2>&1)" + [[ "$output" == *"$version"* ]] || die "installed binary version mismatch" + "$prefix/bin/brew" uninstall --force aiconnai/engram/engram >/dev/null +} + +case "${1:-}" in + --self-test) self_test; exit 0 ;; + --self-test-prefix-isolation) self_test_prefix; exit 0 ;; + --prefix-probe) prefix_probe; exit 0 ;; + --verify-isolated) verify_isolated "${2:-}"; exit 0 ;; +esac +version='' +manifest='' +disposable=false +while [[ $# -gt 0 ]]; do + case "$1" in + --version) version=${2:-}; shift 2 ;; + --manifest) manifest=${2:-}; shift 2 ;; + --disposable-prefix) disposable=true; shift ;; + *) die "unknown argument: $1" ;; + esac +done +validate_version "$version" +[[ "$disposable" == true ]] || die "--disposable-prefix is mandatory" +if [[ -n "$manifest" ]]; then + [[ -f "$manifest" ]] || die "manifest not found" + manifest_version="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("channels",{}).get("homebrew",{}).get("version", d.get("version","")))' "$manifest")" + manifest_version=${manifest_version#v} + [[ "$manifest_version" == "$version" ]] || die "manifest Homebrew version mismatch" +fi +run_isolated_command "$0" --verify-isolated "$version" +echo "verify-homebrew-release: PASS ($version)" diff --git a/scripts/verify-npm-release.sh b/scripts/verify-npm-release.sh new file mode 100755 index 00000000..f1ac8539 --- /dev/null +++ b/scripts/verify-npm-release.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { echo "verify-npm-release: $*" >&2; exit 1; } +verify_json() { + python3 -c 'import json,pathlib,sys +data=json.loads(pathlib.Path(sys.argv[1]).read_text()); package,version=sys.argv[2:] +assert data.get("name")==package and data.get("version")==version, "npm package/version mismatch" +dist=data.get("dist",{}); assert dist.get("integrity") and dist.get("shasum") and dist.get("tarball","").startswith("https://"), "npm release lacks integrity metadata"' "$1" "$2" "$3" +} + +if [[ "${1:-}" == "--self-test" ]]; then + tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT + cat >"$tmp" <<'JSON' +{"name":"engram-client","version":"0.5.0","dist":{"integrity":"sha512-example","shasum":"abc","tarball":"https://registry.npmjs.org/engram-client/-/engram-client-0.5.0.tgz"}} +JSON + verify_json "$tmp" engram-client 0.5.0 + set +e + verify_json "$tmp" engram-client 9.9.9 >/dev/null 2>&1 + status=$? + set -e + [[ $status -ne 0 ]] || die "mismatched version was accepted" + echo "verify-npm-release self-test: PASS" + exit 0 +fi + +package=""; version=""; manifest="" +while [[ $# -gt 0 ]]; do + case "$1" in + --package) package=${2:-}; shift 2 ;; + --version) version=${2:-}; shift 2 ;; + --manifest) manifest=${2:-}; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ -n "$package" && -n "$version" ]] || die "--package and --version are required" +if [[ -n "$manifest" ]]; then + [[ "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["channels"]["npm"]["version"])' "$manifest")" == "$version" ]] || die "manifest version mismatch" +fi +tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT +npm view "${package}@${version}" --json >"$tmp" +verify_json "$tmp" "$package" "$version" +echo "verify-npm-release: PASS" diff --git a/scripts/verify-pypi-release.sh b/scripts/verify-pypi-release.sh new file mode 100755 index 00000000..49f11478 --- /dev/null +++ b/scripts/verify-pypi-release.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { echo "verify-pypi-release: $*" >&2; exit 1; } + +verify_json() { + python3 -c 'import json,pathlib,sys +data=json.loads(pathlib.Path(sys.argv[1]).read_text()); package,version=sys.argv[2:] +assert data.get("info",{}).get("name","").lower().replace("_","-")==package.lower().replace("_","-"), "PyPI package mismatch" +assert data.get("info",{}).get("version")==version, "PyPI version mismatch" +files=data.get("releases",{}).get(version,[]); types={item.get("packagetype") for item in files} +assert {"bdist_wheel","sdist"}.issubset(types), "PyPI release lacks wheel or sdist" +assert all(item.get("digests",{}).get("sha256") for item in files), "PyPI release file lacks SHA-256"' "$1" "$2" "$3" +} + +if [[ "${1:-}" == "--self-test" ]]; then + tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT + cat >"$tmp" <<'JSON' +{"info":{"name":"engram-client","version":"0.5.0"},"releases":{"0.5.0":[{"packagetype":"bdist_wheel","digests":{"sha256":"aa"}},{"packagetype":"sdist","digests":{"sha256":"bb"}}]}} +JSON + verify_json "$tmp" engram-client 0.5.0 + set +e + verify_json "$tmp" engram-client 9.9.9 >/dev/null 2>&1 + status=$? + set -e + [[ $status -ne 0 ]] || die "mismatched version was accepted" + echo "verify-pypi-release self-test: PASS" + exit 0 +fi + +package=""; version=""; manifest="" +while [[ $# -gt 0 ]]; do + case "$1" in + --package) package=${2:-}; shift 2 ;; + --version) version=${2:-}; shift 2 ;; + --manifest) manifest=${2:-}; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ -n "$package" && -n "$version" ]] || die "--package and --version are required" +if [[ -n "$manifest" ]]; then + [[ "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["channels"]["pypi"]["version"])' "$manifest")" == "$version" ]] || die "manifest version mismatch" +fi +tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT +curl --fail --silent --show-error --proto '=https' "https://pypi.org/pypi/${package}/${version}/json" >"$tmp" +verify_json "$tmp" "$package" "$version" +echo "verify-pypi-release: PASS" diff --git a/scripts/verify-release-artifacts.sh b/scripts/verify-release-artifacts.sh new file mode 100755 index 00000000..0bee48ff --- /dev/null +++ b/scripts/verify-release-artifacts.sh @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TARGETS=( + aarch64-apple-darwin + x86_64-apple-darwin + x86_64-unknown-linux-gnu + aarch64-unknown-linux-gnu +) + +die() { echo "verify-release-artifacts: $*" >&2; exit 1; } +sha256_file() { openssl dgst -sha256 -r "$1" | awk '{print $1}'; } + +create_bundle() { + local binary_dir=$1 output_dir=$2 target=$3 version=$4 sha=$5 ref=$6 + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || die "--sha must be a full lowercase commit SHA" + [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "invalid version" + [[ " ${TARGETS[*]} " == *" $target "* ]] || die "unsupported target: $target" + [[ -f "$ROOT/Cargo.lock" ]] || die "Cargo.lock is required" + local binaries=(engram-server engram-cli) + [[ "$target" == *-linux-* ]] && binaries+=(engram-pdf-worker) + local binary + for binary in "${binaries[@]}"; do + [[ -f "$binary_dir/$binary" ]] || die "missing release binary: $binary_dir/$binary" + done + mkdir -p "$output_dir" + local archive="$output_dir/engram-${version}-${target}.tar.gz" + local epoch + epoch="$(git -C "$ROOT" show -s --format=%ct "$sha" 2>/dev/null || printf '0')" + BINARY_DIR="$binary_dir" ARCHIVE="$archive" BINARIES="${binaries[*]}" EPOCH="$epoch" python3 - <<'PY' +import gzip, io, os, tarfile +from pathlib import Path + +binary_dir = Path(os.environ["BINARY_DIR"]) +archive = Path(os.environ["ARCHIVE"]) +epoch = int(os.environ["EPOCH"]) +with archive.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as zipped: + with tarfile.open(fileobj=zipped, mode="w") as tar: + for name in sorted(os.environ["BINARIES"].split()): + data = (binary_dir / name).read_bytes() + info = tarfile.TarInfo(name) + info.size = len(data) + info.mode = 0o755 + info.uid = info.gid = 0 + info.uname = info.gname = "root" + info.mtime = epoch + tar.addfile(info, io.BytesIO(data)) +PY + local digest + digest="$(sha256_file "$archive")" + printf '%s %s\n' "$digest" "$(basename "$archive")" > "$archive.sha256" + + ARCHIVE="$archive" DIGEST="$digest" TARGET="$target" VERSION="$version" \ + SHA="$sha" REF="$ref" CARGO_LOCK="$ROOT/Cargo.lock" python3 - <<'PY' +import hashlib, json, os, re +from pathlib import Path + +archive = Path(os.environ["ARCHIVE"]) +digest = os.environ["DIGEST"] +target = os.environ["TARGET"] +sha = os.environ["SHA"] +ref = os.environ["REF"] +version = os.environ["VERSION"] +packages = [] +current = None +for raw in Path(os.environ["CARGO_LOCK"]).read_text().splitlines(): + line = raw.strip() + if line == "[[package]]": + if current is not None: + packages.append(current) + current = {} + continue + if current is None: + continue + match = re.fullmatch(r'(name|version|source|checksum)\s*=\s*("(?:[^"\\]|\\.)*")', line) + if match: + current[match.group(1)] = json.loads(match.group(2)) +if current is not None: + packages.append(current) +if not packages or any("name" not in package or "version" not in package for package in packages): + raise SystemExit("Cargo.lock package parsing failed") +packages.sort(key=lambda p: (p["name"], p["version"], p.get("source", ""))) + +components = [] +spdx_packages = [] +for index, package in enumerate(packages, 1): + component = {"type": "library", "name": package["name"], "version": package["version"]} + if package.get("source"): + component["purl"] = f"pkg:cargo/{package['name']}@{package['version']}" + if package.get("checksum"): + component["hashes"] = [{"alg": "SHA-256", "content": package["checksum"]}] + components.append(component) + spdx_packages.append({ + "SPDXID": f"SPDXRef-Package-{index}", + "name": package["name"], + "versionInfo": package["version"], + "downloadLocation": package.get("source", "NOASSERTION"), + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + }) + +cyclonedx = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": f"urn:uuid:{digest[:8]}-{digest[8:12]}-{digest[12:16]}-{digest[16:20]}-{digest[20:32]}", + "version": 1, + "metadata": { + "component": {"type": "application", "name": archive.name, "version": version.removeprefix("v")}, + "properties": [ + {"name": "engram:artifact-sha256", "value": digest}, + {"name": "engram:git-commit", "value": sha}, + {"name": "engram:target", "value": target}, + ], + }, + "components": components, +} +spdx = { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": archive.name, + "documentNamespace": f"https://github.com/aiconnai/engram/sbom/{sha}/{target}/{digest}", + "creationInfo": {"created": "1970-01-01T00:00:00Z", "creators": ["Tool: engram-release-v1"]}, + "packages": spdx_packages, + "annotations": [{ + "annotationType": "OTHER", + "annotator": "Tool: engram-release-v1", + "annotationDate": "1970-01-01T00:00:00Z", + "comment": f"artifact-sha256={digest};git-commit={sha};target={target}", + }], +} +provenance = { + "_type": "https://in-toto.io/Statement/v1", + "subject": [{"name": archive.name, "digest": {"sha256": digest}}], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": "https://github.com/aiconnai/engram/.github/workflows/release.yml@v1", + "externalParameters": {"target": target, "version": version, "ref": ref, "sha": sha}, + "resolvedDependencies": [{"uri": "git+https://github.com/aiconnai/engram", "digest": {"gitCommit": sha}}], + }, + "runDetails": { + "builder": {"id": f"https://github.com/aiconnai/engram/actions/runs/{os.getenv('GITHUB_RUN_ID', 'local')}"}, + "metadata": {"invocationId": os.getenv("GITHUB_RUN_ATTEMPT", "local")}, + }, + }, +} +for suffix, value in (("cyclonedx.json", cyclonedx), ("spdx.json", spdx), ("provenance.json", provenance)): + Path(f"{archive}.{suffix}").write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + +def file_hash(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + +attestation = { + "schema_version": "engram-release-attestation-v1", + "artifact": {"name": archive.name, "sha256": digest}, + "commit": sha, + "ref": ref, + "target": target, + "sbom": { + "cyclonedx_sha256": file_hash(f"{archive}.cyclonedx.json"), + "spdx_sha256": file_hash(f"{archive}.spdx.json"), + }, + "provenance_sha256": file_hash(f"{archive}.provenance.json"), +} +Path(f"{archive}.attestation.json").write_text(json.dumps(attestation, indent=2, sort_keys=True) + "\n") +PY + "$ROOT/scripts/sign-candidate-manifest.sh" \ + --manifest "$archive.attestation.json" \ + --signature "$archive.attestation.sig" + echo "created release bundle: $archive" +} + +verify_archive() { + local archive=$1 expected_sha=${2:-} expected_target=${3:-} allow_untrusted=${4:-false} + local checksum="$archive.sha256" cdx="$archive.cyclonedx.json" spdx="$archive.spdx.json" + local provenance="$archive.provenance.json" attestation="$archive.attestation.json" + local signature="$archive.attestation.sig" + for path in "$checksum" "$cdx" "$spdx" "$provenance" "$attestation" "$signature"; do + [[ -f "$path" ]] || die "missing evidence file: $path" + done + local expected_digest expected_name actual_digest + read -r expected_digest expected_name < "$checksum" + expected_name="${expected_name#\*}" + [[ "$expected_name" == "$(basename "$archive")" ]] || die "checksum filename mismatch" + actual_digest="$(sha256_file "$archive")" + [[ "$actual_digest" == "$expected_digest" ]] || die "archive checksum mismatch: $archive" + ARCHIVE="$archive" DIGEST="$actual_digest" EXPECTED_SHA="$expected_sha" \ + EXPECTED_TARGET="$expected_target" python3 - <<'PY' +import hashlib, json, os, tarfile +from pathlib import Path + +archive = Path(os.environ["ARCHIVE"]) +digest = os.environ["DIGEST"] +expected_sha = os.environ["EXPECTED_SHA"] +expected_target = os.environ["EXPECTED_TARGET"] +cdx = json.loads(Path(f"{archive}.cyclonedx.json").read_text()) +spdx = json.loads(Path(f"{archive}.spdx.json").read_text()) +provenance = json.loads(Path(f"{archive}.provenance.json").read_text()) +attestation = json.loads(Path(f"{archive}.attestation.json").read_text()) + +if cdx.get("bomFormat") != "CycloneDX" or cdx.get("specVersion") != "1.5" or not cdx.get("components"): + raise SystemExit("invalid or empty CycloneDX SBOM") +if spdx.get("spdxVersion") != "SPDX-2.3" or not spdx.get("packages"): + raise SystemExit("invalid or empty SPDX SBOM") +subject = provenance.get("subject", [{}])[0] +if subject.get("name") != archive.name or subject.get("digest", {}).get("sha256") != digest: + raise SystemExit("provenance subject does not bind the archive") +parameters = provenance.get("predicate", {}).get("buildDefinition", {}).get("externalParameters", {}) +if expected_sha and parameters.get("sha") != expected_sha: + raise SystemExit("provenance SHA mismatch") +if expected_target and parameters.get("target") != expected_target: + raise SystemExit("provenance target mismatch") +if attestation.get("schema_version") != "engram-release-attestation-v1": + raise SystemExit("invalid attestation schema") +if attestation.get("artifact") != {"name": archive.name, "sha256": digest}: + raise SystemExit("attestation artifact mismatch") +if expected_sha and attestation.get("commit") != expected_sha: + raise SystemExit("attestation commit mismatch") +if expected_target and attestation.get("target") != expected_target: + raise SystemExit("attestation target mismatch") + +def digest_file(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() +if attestation.get("sbom", {}).get("cyclonedx_sha256") != digest_file(f"{archive}.cyclonedx.json"): + raise SystemExit("CycloneDX digest mismatch") +if attestation.get("sbom", {}).get("spdx_sha256") != digest_file(f"{archive}.spdx.json"): + raise SystemExit("SPDX digest mismatch") +if attestation.get("provenance_sha256") != digest_file(f"{archive}.provenance.json"): + raise SystemExit("provenance digest mismatch") +with tarfile.open(archive, "r:gz") as tar: + names = sorted(tar.getnames()) + if any(name.startswith("/") or ".." in Path(name).parts for name in names): + raise SystemExit("unsafe archive member") +target = expected_target or attestation.get("target", "") +wanted = ["engram-cli", "engram-server"] + (["engram-pdf-worker"] if "-linux-" in target else []) +if names != sorted(wanted): + raise SystemExit(f"archive member mismatch: {names} != {sorted(wanted)}") +PY + "$ROOT/scripts/sign-candidate-manifest.sh" --verify "$attestation" --signature "$signature" >/dev/null + local github_bundle="$archive.github-attestation.sigstore.json" + if [[ "$allow_untrusted" == true ]]; then + [[ ! -e "$github_bundle" ]] || die "local-only verification must not ignore a supplied GitHub attestation" + else + [[ "$expected_sha" =~ ^[0-9a-f]{40}$ ]] \ + || die "trusted GitHub attestation verification requires --expected-sha" + [[ -f "$github_bundle" ]] || die "missing identity-bound GitHub attestation: $github_bundle" + command -v gh >/dev/null || die "gh is required to verify GitHub identity attestations" + local signed_file + for signed_file in "$archive" "$checksum" "$cdx" "$spdx" "$provenance" "$attestation" "$signature"; do + gh attestation verify "$signed_file" \ + --bundle "$github_bundle" \ + --repo "${GITHUB_REPOSITORY:-aiconnai/engram}" \ + --signer-workflow aiconnai/engram/.github/workflows/release.yml \ + --source-digest "$expected_sha" \ + >/dev/null + done + fi + echo "verified $(basename "$archive")" +} + +verify_dir() { + local dir=$1 expected_sha=${2:-} target=${3:-} allow_untrusted=${4:-false} + [[ -d "$dir" ]] || die "artifact directory not found: $dir" + local archives=() + while IFS= read -r -d '' path; do archives+=("$path"); done < <(find "$dir" -type f -name 'engram-v*-*.tar.gz' -print0 | sort -z) + [[ ${#archives[@]} -gt 0 ]] || die "no release archives found under $dir" + if [[ -n "$target" ]]; then + [[ ${#archives[@]} -eq 1 ]] || die "target verification requires exactly one archive" + verify_archive "${archives[0]}" "$expected_sha" "$target" "$allow_untrusted" + else + [[ ${#archives[@]} -eq ${#TARGETS[@]} ]] || die "expected ${#TARGETS[@]} archives, found ${#archives[@]}" + local seen=" " archive target_name + for archive in "${archives[@]}"; do + target_name="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["target"])' "$archive.attestation.json")" + [[ " ${TARGETS[*]} " == *" $target_name "* ]] || die "unexpected target: $target_name" + [[ "$seen" != *" $target_name "* ]] || die "duplicate target: $target_name" + seen+="$target_name " + verify_archive "$archive" "$expected_sha" "$target_name" "$allow_untrusted" + done + fi +} + +self_test_tamper() { + local tmp sha target archive + tmp="$(mktemp -d)" + self_test_tmp=$tmp + trap 'rm -rf "${self_test_tmp:-}"' EXIT + mkdir "$tmp/bin" + for binary in engram-server engram-cli; do + printf '#!/bin/sh\necho %s 0.22.0\n' "$binary" > "$tmp/bin/$binary" + chmod +x "$tmp/bin/$binary" + done + sha="$(git -C "$ROOT" rev-parse HEAD)" + target=aarch64-apple-darwin + create_bundle "$tmp/bin" "$tmp/out" "$target" v0.22.0 "$sha" refs/heads/self-test + create_bundle "$tmp/bin" "$tmp/out-repeat" "$target" v0.22.0 "$sha" refs/heads/self-test + [[ "$(sha256_file "$tmp/out/engram-v0.22.0-${target}.tar.gz")" == \ + "$(sha256_file "$tmp/out-repeat/engram-v0.22.0-${target}.tar.gz")" ]] \ + || die "identical inputs did not produce the same archive" + verify_dir "$tmp/out" "$sha" "$target" true >/dev/null + archive="$(find "$tmp/out" -name '*.tar.gz' -type f)" + printf x >> "$archive" + if (verify_dir "$tmp/out" "$sha" "$target" true) >/dev/null 2>&1; then + die "tamper self-test accepted a modified archive" + fi + echo "verify-release-artifacts tamper self-test: PASS" +} + +mode=verify +artifact_dir='' +run_id='' +expected_sha='' +target='' +binary_dir='' +output_dir='' +version='' +sha='' +ref='' +allow_untrusted_local=false +while [[ $# -gt 0 ]]; do + case "$1" in + --create-bundle) mode=create; shift ;; + --self-test-tamper) mode=tamper; shift ;; + --artifact-dir) artifact_dir=${2:-}; shift 2 ;; + --run-id) run_id=${2:-}; shift 2 ;; + --expected-sha) expected_sha=${2:-}; shift 2 ;; + --target) target=${2:-}; shift 2 ;; + --binary-dir) binary_dir=${2:-}; shift 2 ;; + --output-dir) output_dir=${2:-}; shift 2 ;; + --version) version=${2:-}; shift 2 ;; + --sha) sha=${2:-}; shift 2 ;; + --ref) ref=${2:-}; shift 2 ;; + --allow-untrusted-local) allow_untrusted_local=true; shift ;; + -h|--help) sed -n '1,35p' "$0"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done +case "$mode" in + create) + [[ -n "$binary_dir$output_dir$target$version$sha$ref" ]] || die "create mode requires all bundle arguments" + create_bundle "$binary_dir" "$output_dir" "$target" "$version" "$sha" "$ref" + ;; + tamper) self_test_tamper ;; + verify) + cleanup= + if [[ -n "$run_id" ]]; then + cleanup="$(mktemp -d)" + trap 'rm -rf "${cleanup:-}"' EXIT + gh run download "$run_id" --dir "$cleanup" + artifact_dir=$cleanup + [[ -n "$expected_sha" ]] || expected_sha="$(gh run view "$run_id" --json headSha --jq .headSha)" + fi + [[ -n "$artifact_dir" ]] || die "--artifact-dir or --run-id is required" + verify_dir "$artifact_dir" "$expected_sha" "$target" "$allow_untrusted_local" + ;; +esac diff --git a/scripts/verify-sdk-artifacts.sh b/scripts/verify-sdk-artifacts.sh new file mode 100755 index 00000000..fd564656 --- /dev/null +++ b/scripts/verify-sdk-artifacts.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +artifact_dir="" +expected_sha="" +run_id="" +run_live=false +channel="all" +mode="verify" +manifest="" +tmp="" +server_pid="" + +die() { echo "verify-sdk-artifacts: $*" >&2; exit 1; } +cleanup() { + if [[ -n "$server_pid" ]]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + [[ -z "$tmp" ]] || rm -rf "$tmp" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --artifact-dir) artifact_dir=${2:-}; shift 2 ;; + --expected-sha) expected_sha=${2:-}; shift 2 ;; + --manifest) manifest=${2:-}; shift 2 ;; + --run-id) run_id=${2:-}; shift 2 ;; + --channel) channel=${2:-}; shift 2 ;; + --write-metadata) mode="write"; channel=${2:-}; shift 2 ;; + --live) run_live=true; shift ;; + --self-test-version-mismatch) + tmp="$(mktemp -d "${TMPDIR:-/tmp}/engram-sdk-mismatch.XXXXXX")" + python3 -c ' +import io,json,pathlib,tarfile,zipfile,sys +root=pathlib.Path(sys.argv[1]); sha="0"*40 +(root/"python").mkdir(); (root/"npm").mkdir() +with zipfile.ZipFile(root/"python/engram_client-0.5.0-py3-none-any.whl","w") as z: z.writestr("engram_client-0.5.0.dist-info/METADATA","Name: engram-client\nVersion: 0.5.0\n") +with tarfile.open(root/"python/engram_client-0.5.0.tar.gz","w:gz") as t: + data=b"[project]\nname=\"engram-client\"\nversion=\"0.5.0\"\n"; info=tarfile.TarInfo("engram_client-0.5.0/pyproject.toml"); info.size=len(data); t.addfile(info,io.BytesIO(data)) +with tarfile.open(root/"npm/engram-client-0.5.0.tgz","w:gz") as t: + for name,data in [("package/package.json",b"{\"name\":\"engram-client\",\"version\":\"0.5.0\"}"),("package/dist/index.js",b"export {}"),("package/dist/index.d.ts",b"export {}")]: + info=tarfile.TarInfo(name); info.size=len(data); t.addfile(info,io.BytesIO(data)) +base={"repository":"aiconnai/engram","package":"engram-client","sha":sha,"compatible_core_min":"0.20.0","compatible_core_max":"0.22.x","files":[]} +(root/"python/python-sdk-metadata.json").write_text(json.dumps(base|{"channel":"pypi","version":"9.9.9"})) +(root/"npm/npm-sdk-metadata.json").write_text(json.dumps(base|{"channel":"npm","version":"0.5.0"})) +' "$tmp" + set +e + "$0" --artifact-dir "$tmp" --expected-sha "$(printf '0%.0s' {1..40})" >/dev/null 2>&1 + status=$? + set -e + [[ $status -ne 0 ]] || die "version mismatch fixture was accepted" + echo "verify-sdk-artifacts version mismatch self-test: PASS" + rm -rf "$tmp" + exit 0 + ;; + *) die "unknown argument: $1" ;; + esac +done + +if [[ -n "$manifest" ]]; then + [[ -f "$manifest" ]] || die "manifest does not exist: $manifest" + manifest_sha="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["sha"])' "$manifest")" + [[ -z "$expected_sha" || "$expected_sha" == "$manifest_sha" ]] || die "manifest SHA conflicts with --expected-sha" + expected_sha="$manifest_sha" +fi + +if [[ "$mode" == write ]]; then + [[ "$channel" =~ ^(python|npm)$ ]] || die "--write-metadata requires python or npm" + [[ -n "$artifact_dir" && -d "$artifact_dir" ]] || die "--artifact-dir is required" + [[ "$expected_sha" =~ ^[0-9a-f]{40}$ ]] || die "--expected-sha must be a full lowercase SHA" + python3 -c ' +import hashlib,json,pathlib,sys,tomllib +root=pathlib.Path(sys.argv[1]); out=pathlib.Path(sys.argv[2]); sha=sys.argv[3]; sdk=sys.argv[4] +matrix=tomllib.loads((root/"docs/releases/channel-matrix.toml").read_text()) +channel_id="pypi" if sdk=="python" else "npm" +channel=next(item for item in matrix["channels"] if item["id"]==channel_id) +compat=next(item for item in matrix["sdk_compatibility"] if item["sdk"]==sdk) +files=[{"name":path.name,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()} for path in sorted(out.iterdir())] +metadata={"repository":"aiconnai/engram","channel":channel_id,"package":channel["package"],"version":channel["local_version"],"sha":sha,"compatible_core_min":compat["compatible_core_min"],"compatible_core_max":compat["compatible_core_max"],"files":files} +(out/("python-sdk-metadata.json" if sdk=="python" else "npm-sdk-metadata.json")).write_text(json.dumps(metadata,sort_keys=True,indent=2)+"\n") +' "$repo_root" "$artifact_dir" "$expected_sha" "$channel" + echo "verify-sdk-artifacts metadata: PASS ($channel)" + exit 0 +fi + +if [[ -n "$run_id" ]]; then + [[ -z "$artifact_dir" ]] || die "use either --run-id or --artifact-dir" + command -v gh >/dev/null || die "gh is required for --run-id" + tmp="$(mktemp -d "${TMPDIR:-/tmp}/engram-sdk-artifacts.XXXXXX")" + gh run download "$run_id" --dir "$tmp" + artifact_dir="$tmp" + if [[ -z "$expected_sha" ]]; then + expected_sha="$(gh run view "$run_id" --json headSha --jq .headSha)" + fi +fi + +[[ -n "$artifact_dir" && -d "$artifact_dir" ]] || die "--artifact-dir or --run-id is required" +[[ -n "$expected_sha" ]] || expected_sha="$(git -C "$repo_root" rev-parse HEAD)" +[[ "$expected_sha" =~ ^[0-9a-f]{40}$ ]] || die "expected SHA must be a full lowercase commit SHA" +[[ "$channel" =~ ^(all|python|npm)$ ]] || die "--channel must be all, python, or npm" + +[[ -n "$tmp" ]] || tmp="$(mktemp -d "${TMPDIR:-/tmp}/engram-sdk-install.XXXXXX")" +trap cleanup EXIT +python3 -c ' +import hashlib +import json +import pathlib +import re +import sys +import tarfile +import tomllib +import zipfile + +root = pathlib.Path(sys.argv[1]) +artifacts = pathlib.Path(sys.argv[2]) +expected_sha = sys.argv[3] +selected = sys.argv[4] +matrix = tomllib.loads((root / "docs/releases/channel-matrix.toml").read_text()) +channels = {item["id"]: item for item in matrix["channels"]} +compat = {item["sdk"]: item for item in matrix["sdk_compatibility"]} + +def exactly(pattern: str) -> pathlib.Path: + matches = sorted(artifacts.rglob(pattern)) + if len(matches) != 1: + raise SystemExit(f"expected exactly one {pattern}, found {len(matches)}") + return matches[0] + +def digest(path: pathlib.Path) -> str: + h = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + +def validate_metadata(path: pathlib.Path, channel: str, version: str, expected_files: set[str]) -> None: + value = json.loads(path.read_text()) + expected = { + "repository": "aiconnai/engram", + "channel": channel, + "package": "engram-client", + "version": version, + "sha": expected_sha, + "compatible_core_min": compat["python" if channel == "pypi" else "typescript"]["compatible_core_min"], + "compatible_core_max": compat["python" if channel == "pypi" else "typescript"]["compatible_core_max"], + } + for key, expected_value in expected.items(): + if value.get(key) != expected_value: + raise SystemExit(f"{channel} metadata mismatch for {key}: {value.get(key)!r} != {expected_value!r}") + items = value.get("files") + if not isinstance(items, list) or len(items) != len(expected_files): + raise SystemExit(f"{channel} metadata must bind exactly {sorted(expected_files)}") + names = [item.get("name") for item in items if isinstance(item, dict)] + if len(names) != len(set(names)) or set(names) != expected_files: + raise SystemExit(f"{channel} metadata file set mismatch: {names}") + for item in items: + if not isinstance(item.get("sha256"), str) or re.fullmatch(r"[0-9a-f]{64}", item["sha256"]) is None: + raise SystemExit(f"{channel} invalid SHA-256 metadata") + candidate = path.parent / item["name"] + if not candidate.is_file() or digest(candidate) != item["sha256"]: + raise SystemExit(f"{channel} checksum mismatch: {candidate.name}") + +verified = [] +if selected in {"all", "python"}: + python_version = channels["pypi"]["local_version"] + wheel = exactly("*.whl") + sdist = exactly("*.tar.gz") + python_meta = exactly("python-sdk-metadata.json") + validate_metadata(python_meta, "pypi", python_version, {wheel.name, sdist.name}) + with zipfile.ZipFile(wheel) as archive: + metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + if len(metadata_names) != 1: + raise SystemExit("wheel must contain exactly one METADATA file") + metadata = archive.read(metadata_names[0]).decode() + if f"Name: engram-client\n" not in metadata or f"Version: {python_version}\n" not in metadata: + raise SystemExit("wheel metadata does not match channel matrix") + with tarfile.open(sdist, "r:gz") as archive: + pyprojects = [member for member in archive.getmembers() if member.name.endswith("/pyproject.toml")] + if len(pyprojects) != 1: + raise SystemExit("sdist must contain exactly one pyproject.toml") + project = tomllib.loads(archive.extractfile(pyprojects[0]).read().decode())["project"] + if project.get("name") != "engram-client" or project.get("version") != python_version: + raise SystemExit("sdist metadata does not match channel matrix") + verified.append(f"Python {python_version}") +if selected in {"all", "npm"}: + npm_version = channels["npm"]["local_version"] + npm = exactly("*.tgz") + npm_meta = exactly("npm-sdk-metadata.json") + validate_metadata(npm_meta, "npm", npm_version, {npm.name}) + with tarfile.open(npm, "r:gz") as archive: + package_member = archive.getmember("package/package.json") + package = json.loads(archive.extractfile(package_member).read()) + if package.get("name") != "engram-client" or package.get("version") != npm_version: + raise SystemExit("npm tarball metadata does not match channel matrix") + files = {member.name for member in archive.getmembers()} + if "package/dist/index.js" not in files or "package/dist/index.d.ts" not in files: + raise SystemExit("npm tarball is missing compiled entry points") + verified.append(f"npm {npm_version}") +print("SDK artifacts verified for {}: {}".format(expected_sha, ", ".join(verified))) +' "$repo_root" "$artifact_dir" "$expected_sha" "$channel" + +if [[ "$channel" == all || "$channel" == python ]]; then + wheel="$(find "$artifact_dir" -type f -name '*.whl' -print -quit)" + python3 -m venv "$tmp/venv" + "$tmp/venv/bin/python" -m pip install --quiet "$wheel" + "$tmp/venv/bin/python" -I -c 'import engram_client; assert engram_client.__file__' +fi +if [[ "$channel" == all || "$channel" == npm ]]; then + tarball="$(find "$artifact_dir" -type f -name '*.tgz' -print -quit)" + mkdir -p "$tmp/npm-consumer" + ( + cd "$tmp/npm-consumer" + npm init --yes >/dev/null + npm install --ignore-scripts --no-audit --no-fund "$tarball" >/dev/null + node --input-type=module -e 'import("engram-client").then(m => { if (!m.EngramClient) process.exit(1) })' + ) +fi + +if [[ "$run_live" == true ]]; then + (cd "$repo_root" && cargo build --quiet --bin engram-server --features dream-phase) + port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()')" + api_key="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" + ENGRAM_HTTP_API_KEY="$api_key" ENGRAM_EMBEDDING_MODEL=tfidf \ + "$repo_root/target/debug/engram-server" --transport http \ + --http-bind-address 127.0.0.1 --http-port "$port" \ + --db-path "$tmp/sdk-live.db" >"$tmp/server.log" 2>&1 & + server_pid=$! + ready=false + for _ in $(seq 1 200); do + if curl --fail --silent "http://127.0.0.1:$port/health" >/dev/null; then ready=true; break; fi + kill -0 "$server_pid" 2>/dev/null || break + sleep 0.1 + done + [[ "$ready" == true ]] || die "artifact-backed server did not become ready" + + if [[ "$channel" == all || "$channel" == python ]]; then + "$tmp/venv/bin/python" -m pip install --quiet pytest pytest-asyncio + ENGRAM_LIVE_BASE_URL="http://127.0.0.1:$port" \ + ENGRAM_LIVE_API_KEY="$api_key" ENGRAM_LIVE_TENANT="python-sdk-artifact" \ + ENGRAM_LIVE_VENV="$tmp/venv" ENGRAM_LIVE_SCENARIO=happy \ + "$tmp/venv/bin/python" -I -m pytest --import-mode=importlib \ + "$repo_root/sdks/python/tests/test_live_client.py" -q + fi + + if [[ "$channel" == all || "$channel" == npm ]]; then + ( + cd "$tmp/npm-consumer" + ENGRAM_LIVE_BASE_URL="http://127.0.0.1:$port" \ + ENGRAM_LIVE_API_KEY="$api_key" node --input-type=module -e ' + import { EngramClient, EngramError } from "engram-client"; + const payload = result => JSON.parse(result.content[0].text); + const client = new EngramClient({baseUrl: process.env.ENGRAM_LIVE_BASE_URL, apiKey: process.env.ENGRAM_LIVE_API_KEY, tenant: "npm-sdk-artifact", timeout: 5000}); + const created = payload(await client.create("npm artifact live contract", {workspace: "npm-sdk-artifact"})); + if (payload(await client.get(created.id)).id !== created.id) throw new Error("npm get mismatch"); + if (!JSON.stringify(payload(await client.search("artifact live", {workspace: "npm-sdk-artifact"}))).includes(String(created.id))) throw new Error("npm search mismatch"); + if (payload(await client.update(created.id, {content: "npm artifact updated"})).content !== "npm artifact updated") throw new Error("npm update mismatch"); + if (payload(await client.delete(created.id)).deleted !== created.id) throw new Error("npm delete mismatch"); + const denied = new EngramClient({baseUrl: process.env.ENGRAM_LIVE_BASE_URL, apiKey: process.env.ENGRAM_LIVE_API_KEY + "-wrong", tenant: "npm-sdk-artifact", timeout: 2000}); + try { await denied.stats(); throw new Error("wrong bearer accepted"); } catch (error) { if (!(error instanceof EngramError) || !error.message.includes("HTTP 401")) throw error; } + ' + ) + echo "verify-sdk-artifacts wrong-auth negative: PASS" + fi + + for content in "workspace boundary source one" "workspace boundary source two"; do + curl --fail --silent --show-error -X POST "http://127.0.0.1:$port/mcp" \ + -H "Authorization: Bearer $api_key" \ + -H 'Content-Type: application/json' \ + --data "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\",\"params\":{\"name\":\"memory_create\",\"arguments\":{\"content\":\"$content\",\"workspace\":\"default\"}}}" \ + >/dev/null + done + curl --fail --silent --show-error -X POST "http://127.0.0.1:$port/mcp" \ + -H "Authorization: Bearer $api_key" \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":92,"method":"tools/call","params":{"name":"dream_create","arguments":{"job_id":"sdk-negative-workspace-job","workspace":"default","run":true,"max_candidates":1,"summary_min_memories":2}}}' \ + >"$tmp/dream-create.json" + dream_candidate_id="$(python3 -c ' +import json, pathlib, sys +outer=json.loads(pathlib.Path(sys.argv[1]).read_text()) +inner=json.loads(outer["result"]["content"][0]["text"]) +ids=inner.get("report",{}).get("candidate_ids",[]) +if len(ids) != 1: raise SystemExit(f"expected one dream candidate, got {inner}") +print(ids[0]) +' "$tmp/dream-create.json")" + memory_count_before="$(python3 -c 'import sqlite3,sys; print(sqlite3.connect(sys.argv[1]).execute("SELECT COUNT(*) FROM memories").fetchone()[0])' "$tmp/sdk-live.db")" + + kill "$server_pid" + wait "$server_pid" 2>/dev/null || true + server_pid="" + anonymous_port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()')" + ENGRAM_EMBEDDING_MODEL=tfidf \ + "$repo_root/target/debug/engram-server" --transport http \ + --http-bind-address 127.0.0.1 --http-port "$anonymous_port" \ + --db-path "$tmp/sdk-live.db" >"$tmp/anonymous-server.log" 2>&1 & + server_pid=$! + ready=false + for _ in $(seq 1 200); do + if curl --fail --silent "http://127.0.0.1:$anonymous_port/health" >/dev/null; then ready=true; break; fi + kill -0 "$server_pid" 2>/dev/null || break + sleep 0.1 + done + [[ "$ready" == true ]] || die "anonymous workspace-boundary server did not become ready" + + for attempt in get review apply reject; do + case "$attempt" in + get) tool="dream_candidate_get"; arguments="{\"id\":\"$dream_candidate_id\",\"workspace\":\"other\"}" ;; + review) tool="dream_candidate_review"; arguments="{\"id\":\"$dream_candidate_id\",\"review_state\":\"accepted\",\"workspace\":\"other\"}" ;; + apply) tool="dream_candidate_apply"; arguments="{\"id\":\"$dream_candidate_id\",\"confirm\":true,\"workspace\":\"other\"}" ;; + reject) tool="dream_candidate_review"; arguments="{\"id\":\"$dream_candidate_id\",\"review_state\":\"rejected\",\"workspace\":\"other\"}" ;; + esac + status="$(curl --silent --show-error -o "$tmp/cross-workspace-$attempt.json" -w '%{http_code}' \ + -X POST "http://127.0.0.1:$anonymous_port/mcp" \ + -H 'Content-Type: application/json' \ + --data "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\",\"params\":{\"name\":\"$tool\",\"arguments\":$arguments}}")" + [[ "$status" == 403 ]] || die "cross-workspace dream $attempt returned HTTP $status, expected 403" + python3 -c ' +import json,pathlib,sys +value=json.loads(pathlib.Path(sys.argv[1]).read_text()) +outer=value.get("error",{}) +try: denial=json.loads(outer.get("message",""))["error"] +except (KeyError,TypeError,json.JSONDecodeError): denial={} +if outer.get("code") != -32003 or denial.get("code") != "permission_denied": + raise SystemExit(f"cross-workspace dream denial was not stable: {value}") +if sys.argv[2] == "get" and denial.get("current_mode") != denial.get("required_mode"): + raise SystemExit(f"candidate get was denied for mode rather than workspace: {value}") +' "$tmp/cross-workspace-$attempt.json" "$attempt" + done + kill "$server_pid" + wait "$server_pid" 2>/dev/null || true + server_pid="" + python3 -c ' +import sqlite3,sys +conn=sqlite3.connect(sys.argv[1]); candidate=sys.argv[2]; before=int(sys.argv[3]) +row=conn.execute("SELECT review_state, application_result_json, applied_at FROM dream_candidates WHERE id = ?", (candidate,)).fetchone() +if row != ("pending", None, None): raise SystemExit(f"cross-workspace attempt mutated candidate: {row}") +after=conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0] +if after != before: raise SystemExit(f"cross-workspace attempt mutated memories: {before} -> {after}") +' "$tmp/sdk-live.db" "$dream_candidate_id" "$memory_count_before" + echo "verify-sdk-artifacts invalid-dream-workspace negative: PASS" +fi + +echo "verify-sdk-artifacts: PASS" diff --git a/sdks/python/README.md b/sdks/python/README.md index 48eaddcc..137450ad 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -12,6 +12,19 @@ Python client for [Engram Cloud](https://github.com/aiconnai/engram-cloud) - AI pip install engram-client ``` +## Testing the local package + +From the Engram repository root, run: + +```bash +bash scripts/test-python-sdk-live.sh +``` + +The script builds a wheel, installs it in an isolated virtual environment, and +tests the public async client against a disposable authenticated Engram HTTP +server. It uses only loopback networking and temporary state; no published +package, external service, or API credential is required. + ## Quick Start ```python diff --git a/sdks/python/engram_client/__init__.py b/sdks/python/engram_client/__init__.py index 1709e87b..419cba29 100644 --- a/sdks/python/engram_client/__init__.py +++ b/sdks/python/engram_client/__init__.py @@ -1,6 +1,7 @@ """Engram Cloud Python SDK - AI memory infrastructure client.""" -from engram_client.client import EngramClient, EngramError +from engram_client.client import EngramClient +from engram_client.errors import EngramError __version__ = "0.5.0" __all__ = ["EngramClient", "EngramError"] diff --git a/sdks/python/engram_client/client.py b/sdks/python/engram_client/client.py index a095bf72..01ff827e 100644 --- a/sdks/python/engram_client/client.py +++ b/sdks/python/engram_client/client.py @@ -5,34 +5,17 @@ from itertools import count from typing import Any -import httpx - +from .errors import EngramError +from .mcp_result import post_tool_call -class EngramError(Exception): - """Custom exception for Engram client errors.""" +import httpx - pass +# Re-export for callers that historically imported from client. +__all__ = ["EngramClient", "EngramError"] class EngramClient: - """Client for the Engram Cloud REST API. - - Usage: - client = EngramClient( - base_url="https://your-engram-cloud.fly.dev", - api_key="ek_...", - tenant="my-tenant", - ) - - # Create a memory - memory = client.create("User prefers dark mode", tags=["prefs"]) - - # Search - results = client.search("user preferences") - - # List - memories = client.list(limit=10) - """ + """Async Engram Cloud client over authenticated MCP-HTTP.""" def __init__( self, @@ -67,33 +50,17 @@ async def __aenter__(self) -> "EngramClient": async def __aexit__(self, *args: Any) -> None: await self.close() - # -- MCP-over-HTTP helpers -- - async def _mcp_call(self, method: str, params: dict[str, Any] | None = None) -> Any: """Execute an MCP tool call over HTTP.""" client = self._client if client is None: raise EngramError("EngramClient is closed") - payload = { - "jsonrpc": "2.0", - "id": next(self._id_counter), - "method": "tools/call", - "params": { - "name": method, - "arguments": params or {}, - }, - } - resp = await client.post("/v1/mcp", json=payload) - try: - resp.raise_for_status() - except httpx.HTTPStatusError as e: - raise EngramError( - f"HTTP {e.response.status_code}: {e.response.text}" - ) from e - result = resp.json() - if "error" in result: - raise EngramError(result["error"].get("message", "Unknown error")) - return result.get("result", {}) + return await post_tool_call( + client, + request_id=next(self._id_counter), + method=method, + params=params, + ) # -- Memory CRUD -- @@ -172,7 +139,7 @@ async def list( filter_: dict[str, Any] | None = None, sort_by: str | None = None, sort_order: str | None = None, - ) -> dict[str, Any]: + ) -> list[dict[str, Any]]: """List memories with optional filters. Advanced filtering is supported via the ``filter_`` parameter, which is diff --git a/sdks/python/engram_client/errors.py b/sdks/python/engram_client/errors.py new file mode 100644 index 00000000..925467c5 --- /dev/null +++ b/sdks/python/engram_client/errors.py @@ -0,0 +1,5 @@ +"""Shared client exceptions.""" + + +class EngramError(Exception): + """Raised when an Engram API call fails.""" diff --git a/sdks/python/engram_client/mcp_result.py b/sdks/python/engram_client/mcp_result.py new file mode 100644 index 00000000..d4785d90 --- /dev/null +++ b/sdks/python/engram_client/mcp_result.py @@ -0,0 +1,97 @@ +"""Decode MCP CallToolResult payloads for the Engram HTTP client.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx + +from .errors import EngramError + + +async def post_tool_call( + client: httpx.AsyncClient, + *, + request_id: int, + method: str, + params: dict[str, Any] | None, +) -> Any: + """POST a tools/call JSON-RPC request and decode the tool result.""" + payload = { + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": { + "name": method, + "arguments": params or {}, + }, + } + try: + resp = await client.post("/v1/mcp", json=payload) + except httpx.RequestError as exc: + raise EngramError(f"Engram request failed: {exc}") from exc + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + raise EngramError( + f"HTTP {exc.response.status_code}: {exc.response.text}" + ) from exc + try: + result = resp.json() + except ValueError as exc: + raise EngramError("Engram returned invalid JSON") from exc + if not isinstance(result, dict): + raise EngramError("Engram returned an invalid JSON-RPC response") + if "error" in result: + error = result["error"] + message = ( + error.get("message", "Unknown error") + if isinstance(error, dict) + else error + ) + raise EngramError(str(message)) + return decode_tool_result(result.get("result", {})) + + +def error_message(error: Any) -> str: + """Extract a human-readable message from an MCP/JSON-RPC error payload.""" + if isinstance(error, dict): + message = error.get("message") + if message is not None: + return str(message) + return str(error) + + +def decode_tool_result(result: Any) -> Any: + """Decode an MCP ``CallToolResult`` while preserving legacy responses.""" + if not isinstance(result, dict) or not isinstance(result.get("content"), list): + return result + + text = next( + ( + block.get("text") + for block in result["content"] + if isinstance(block, dict) + and block.get("type") == "text" + and isinstance(block.get("text"), str) + ), + None, + ) + if text is None: + raise EngramError("Engram MCP response did not contain text content") + try: + decoded = json.loads(text) + except json.JSONDecodeError as exc: + if result.get("isError") is True: + raise EngramError(text) from exc + raise EngramError("Engram MCP response contained invalid JSON") from exc + + if result.get("isError") is True: + error = ( + decoded.get("error", decoded) if isinstance(decoded, dict) else decoded + ) + raise EngramError(error_message(error)) + if isinstance(decoded, dict) and "error" in decoded: + raise EngramError(error_message(decoded["error"])) + return decoded diff --git a/sdks/python/tests/test_client.py b/sdks/python/tests/test_client.py index 4d64a276..b6b62767 100644 --- a/sdks/python/tests/test_client.py +++ b/sdks/python/tests/test_client.py @@ -125,6 +125,73 @@ async def test_mcp_call_jsonrpc_error(self, mock_client): with pytest.raises(EngramError, match="Invalid params"): await mock_client._mcp_call("memory_get", {"id": "invalid"}) + @pytest.mark.asyncio + async def test_mcp_call_decodes_call_tool_content(self, mock_client): + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = { + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + { + "type": "text", + "text": '{"id":123,"content":"live response"}', + } + ] + }, + } + mock_client._client.post.return_value = mock_response + + result = await mock_client._mcp_call("memory_get", {"id": 123}) + + assert result == {"id": 123, "content": "live response"} + + @pytest.mark.asyncio + async def test_mcp_call_decodes_tool_error(self, mock_client): + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = { + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + {"type": "text", "text": '{"error":"Memory 999 not found"}'} + ] + }, + } + mock_client._client.post.return_value = mock_response + + with pytest.raises(EngramError, match="Memory 999 not found"): + await mock_client._mcp_call("memory_get", {"id": 999}) + + @pytest.mark.asyncio + async def test_mcp_call_preserves_plain_text_tool_error(self, mock_client): + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = { + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"type": "text", "text": "permission denied"}], + "isError": True, + }, + } + mock_client._client.post.return_value = mock_response + + with pytest.raises(EngramError, match="permission denied"): + await mock_client._mcp_call("memory_list", {}) + + @pytest.mark.asyncio + async def test_mcp_call_wraps_transport_error(self, mock_client): + request = httpx.Request("POST", "https://test.engram.dev/v1/mcp") + mock_client._client.post.side_effect = httpx.ConnectError( + "connection refused", request=request + ) + + with pytest.raises(EngramError, match="Engram request failed"): + await mock_client._mcp_call("memory_list", {}) + @pytest.mark.asyncio async def test_mcp_call_increments_id(self, mock_client, mock_response): mock_client._client.post.return_value = mock_response diff --git a/sdks/python/tests/test_live_client.py b/sdks/python/tests/test_live_client.py new file mode 100644 index 00000000..e1fc3446 --- /dev/null +++ b/sdks/python/tests/test_live_client.py @@ -0,0 +1,116 @@ +"""Live package-contract tests for the installed Python SDK wheel.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import cast + +import pytest + +import engram_client +from engram_client import EngramClient, EngramError + + +def live_config() -> tuple[str, str, str, str]: + """Read the disposable server contract injected by the live test driver.""" + names = ( + "ENGRAM_LIVE_BASE_URL", + "ENGRAM_LIVE_API_KEY", + "ENGRAM_LIVE_TENANT", + "ENGRAM_LIVE_VENV", + ) + values = tuple(os.environ.get(name) for name in names) + if any(value is None for value in values): + pytest.skip( + "live SDK server is not configured; use scripts/test-python-sdk-live.sh" + ) + return cast(tuple[str, str, str, str], values) + + +def assert_installed_wheel(venv: str) -> None: + """Reject accidental imports from the SDK source tree.""" + package_path = Path(engram_client.__file__).resolve() + assert package_path.is_relative_to(Path(venv).resolve()), package_path + assert "sdks/python/engram_client" not in package_path.as_posix() + + +@pytest.mark.asyncio +async def test_live_public_memory_contract() -> None: + base_url, api_key, tenant, venv = live_config() + if os.environ.get("ENGRAM_LIVE_SCENARIO", "happy") != "happy": + pytest.skip("happy-path contract is not selected") + assert_installed_wheel(venv) + + content = "Python SDK live wheel remembers the indigo launch contract" + updated_content = f"{content} updated" + client = EngramClient(base_url, api_key, tenant, timeout=5.0) + try: + created = await client.create( + content, + workspace=tenant, + tags=["python-sdk-live"], + metadata={"contract": "installed-wheel"}, + ) + memory_id = created["id"] + assert created["content"] == content + + fetched = await client.get(memory_id) + assert fetched["id"] == memory_id + assert fetched["content"] == content + + listed = await client.list(workspace=tenant) + assert any(memory["id"] == memory_id for memory in listed) + + searched = await client.search("indigo launch contract", workspace=tenant) + assert str(memory_id) in json.dumps(searched) + assert content in json.dumps(searched) + + updated = await client.update(memory_id, content=updated_content) + assert updated["content"] == updated_content + + deleted = await client.delete(memory_id) + assert deleted["deleted"] == memory_id + with pytest.raises(EngramError): + await client.get(memory_id) + finally: + await client.close() + + with pytest.raises(EngramError, match="closed"): + await client.list(workspace=tenant) + + async with EngramClient(base_url, api_key, tenant, timeout=5.0) as managed: + context_memory = await managed.create( + "Python SDK async context manager contract", + workspace=tenant, + ) + context_id = context_memory["id"] + assert (await managed.get(context_id))["id"] == context_id + await managed.delete(context_id) + with pytest.raises(EngramError, match="closed"): + await managed.get(context_id) + + +@pytest.mark.asyncio +async def test_live_wrong_bearer_is_typed() -> None: + base_url, api_key, tenant, venv = live_config() + if os.environ.get("ENGRAM_LIVE_SCENARIO") != "wrong_bearer": + pytest.skip("wrong-bearer contract is not selected") + assert_installed_wheel(venv) + + async with EngramClient(base_url, api_key, tenant, timeout=5.0) as client: + with pytest.raises(EngramError, match="HTTP 401"): + await client.list(workspace=tenant) + + +@pytest.mark.asyncio +async def test_live_killed_server_is_typed() -> None: + base_url, api_key, tenant, venv = live_config() + if os.environ.get("ENGRAM_LIVE_SCENARIO") != "killed_server": + pytest.skip("killed-server contract is not selected") + assert_installed_wheel(venv) + + async with EngramClient(base_url, api_key, tenant, timeout=1.0) as client: + with pytest.raises(EngramError, match="Engram request failed"): + await client.list(workspace=tenant) diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index 152fb8d0..9ae381e1 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -1,15 +1,1621 @@ { "name": "engram-client", - "version": "0.1.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "engram-client", - "version": "0.1.0", + "version": "0.5.0", "license": "MIT", "devDependencies": { - "typescript": "^5.3.0" + "@types/node": "^20.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" } }, "node_modules/typescript": { @@ -25,6 +1631,215 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index f541721d..ecf535c3 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -2,6 +2,7 @@ "name": "engram-client", "version": "0.5.0", "description": "TypeScript client for Engram Cloud - AI memory infrastructure", + "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -9,7 +10,7 @@ ], "scripts": { "build": "tsc", - "test": "vitest run", + "test": "vitest run src/index.test.ts", "test:watch": "vitest", "type-check": "tsc --noEmit", "prepublishOnly": "npm run build" diff --git a/sdks/typescript/test/live-client.test.ts b/sdks/typescript/test/live-client.test.ts new file mode 100644 index 00000000..02c7f808 --- /dev/null +++ b/sdks/typescript/test/live-client.test.ts @@ -0,0 +1,132 @@ +import { EngramClient, EngramError } from "engram-client"; + +export interface LiveClientConfig { + baseUrl: string; + apiKey: string; + tenant: string; + mode: "happy" | "wrong-bearer" | "missing-endpoint"; + marker: string; +} + +type JsonObject = Record; + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function object(value: unknown, label: string): JsonObject { + assert( + typeof value === "object" && value !== null && !Array.isArray(value), + `${label} must be an object` + ); + return value as JsonObject; +} + +function array(value: unknown, label: string): unknown[] { + assert(Array.isArray(value), `${label} must be an array`); + return value; +} + +function number(value: unknown, label: string): number { + assert(typeof value === "number", `${label} must be a number`); + return value; +} + +function string(value: unknown, label: string): string { + assert(typeof value === "string", `${label} must be a string`); + return value; +} + +function toolPayload(result: unknown): unknown { + const envelope = object(result, "tool result"); + const content = array(envelope.content, "tool result content"); + assert(content.length > 0, "tool result content must not be empty"); + const first = object(content[0], "tool result content item"); + assert(first.type === "text", "tool result must contain text content"); + return JSON.parse(string(first.text, "tool result text")) as unknown; +} + +function hasMemoryId(value: unknown, expectedId: number): boolean { + if (Array.isArray(value)) { + return value.some((entry) => hasMemoryId(entry, expectedId)); + } + if (typeof value !== "object" || value === null) { + return false; + } + const record = value as JsonObject; + return ( + record.id === expectedId || + Object.values(record).some((entry) => hasMemoryId(entry, expectedId)) + ); +} + +async function expectHttpError( + client: EngramClient, + expectedStatus: number +): Promise { + const started = Date.now(); + try { + await client.stats(); + throw new Error(`request unexpectedly succeeded; wanted HTTP ${expectedStatus}`); + } catch (error: unknown) { + assert(error instanceof EngramError, "failure must be an EngramError"); + assert( + error.message.includes(`HTTP ${expectedStatus}`), + `expected HTTP ${expectedStatus}, received: ${error.message}` + ); + } + assert(Date.now() - started < 5_000, "error request exceeded the live-test bound"); +} + +export async function runLiveClientContract( + config: LiveClientConfig +): Promise { + const client = new EngramClient({ + baseUrl: config.baseUrl, + apiKey: config.apiKey, + tenant: config.tenant, + timeout: 2_500, + }); + + if (config.mode === "wrong-bearer") { + await expectHttpError(client, 401); + return; + } + if (config.mode === "missing-endpoint") { + await expectHttpError(client, 404); + return; + } + + const workspace = "typescript-live"; + const original = `TypeScript live package ${config.marker}`; + const updated = `${original} updated`; + + const created = object(toolPayload(await client.create(original, { + workspace, + tags: ["typescript-live"], + })), "memory_create payload"); + const memoryId = number(created.id, "created memory id"); + assert(created.content === original, "create did not preserve content"); + + const fetched = object(toolPayload(await client.get(memoryId)), "memory_get payload"); + assert(fetched.id === memoryId, "get returned the wrong memory"); + assert(fetched.content === original, "get returned the wrong content"); + + const listed = toolPayload(await client.list({ workspace })); + assert(hasMemoryId(listed, memoryId), "list did not contain the created memory"); + + const searched = toolPayload(await client.search(config.marker, { workspace })); + assert(hasMemoryId(searched, memoryId), "search did not contain the created memory"); + + const changed = object( + toolPayload(await client.update(memoryId, { content: updated })), + "memory_update payload" + ); + assert(changed.id === memoryId, "update returned the wrong memory"); + assert(changed.content === updated, "update did not persist content"); + + const deleted = object(toolPayload(await client.delete(memoryId)), "memory_delete payload"); + assert(deleted.deleted === memoryId, "delete returned the wrong memory id"); +} diff --git a/src/mcp/handlers/memory_crud/create.rs b/src/mcp/handlers/memory_crud/create.rs index 22a49934..2f5c6564 100644 --- a/src/mcp/handlers/memory_crud/create.rs +++ b/src/mcp/handlers/memory_crud/create.rs @@ -119,6 +119,7 @@ pub fn memory_create(ctx: &HandlerContext, params: Value) -> Value { manager.broadcast(RealtimeEvent::memory_created( memory.id, memory.content.clone(), + memory.workspace.clone(), )); } json!(memory) @@ -304,6 +305,7 @@ pub fn context_seed(ctx: &HandlerContext, params: Value) -> Value { manager.broadcast(RealtimeEvent::memory_created( memory.id, memory.content.clone(), + memory.workspace.clone(), )); } } diff --git a/src/mcp/handlers/memory_crud/facts.rs b/src/mcp/handlers/memory_crud/facts.rs index 62a118ea..ab891a65 100644 --- a/src/mcp/handlers/memory_crud/facts.rs +++ b/src/mcp/handlers/memory_crud/facts.rs @@ -101,6 +101,7 @@ pub fn memory_ingest_fact(ctx: &HandlerContext, params: Value) -> Value { manager.broadcast(RealtimeEvent::memory_created( memory.id, memory.content.clone(), + memory.workspace.clone(), )); } json!({"id": memory.id, "created": true}) @@ -241,6 +242,7 @@ pub fn memory_ingest_fact_batch(ctx: &HandlerContext, params: Value) -> Value { manager.broadcast(RealtimeEvent::memory_created( memory.id, memory.content.clone(), + memory.workspace.clone(), )); } } diff --git a/src/mcp/handlers/memory_crud/read_update_delete.rs b/src/mcp/handlers/memory_crud/read_update_delete.rs index 94f5233e..379fe29c 100644 --- a/src/mcp/handlers/memory_crud/read_update_delete.rs +++ b/src/mcp/handlers/memory_crud/read_update_delete.rs @@ -89,7 +89,11 @@ pub fn memory_update(ctx: &HandlerContext, params: Value) -> Value { Ok(memory) => { ctx.search_cache.invalidate_for_memory(memory.id); if let Some(ref manager) = ctx.realtime { - manager.broadcast(RealtimeEvent::memory_updated(memory.id, changes)); + manager.broadcast(RealtimeEvent::memory_updated( + memory.id, + changes, + memory.workspace.clone(), + )); } json!(memory) } @@ -133,15 +137,16 @@ pub fn memory_delete(ctx: &HandlerContext, params: Value) -> Value { }, ); } - Ok(chain) + Ok((chain, workspace)) }); match result { - Ok(deleted_ids) => { + Ok((deleted_ids, workspace)) => { for &deleted_id in &deleted_ids { ctx.search_cache.invalidate_for_memory(deleted_id); - if let Some(ref manager) = ctx.realtime { - manager.broadcast(RealtimeEvent::memory_deleted(deleted_id)); + if let (Some(manager), Some(workspace)) = (&ctx.realtime, workspace.as_deref()) + { + manager.broadcast(RealtimeEvent::memory_deleted(deleted_id, workspace)); } } let count = deleted_ids.len(); @@ -171,14 +176,14 @@ pub fn memory_delete(ctx: &HandlerContext, params: Value) -> Value { dry_run: false, }, ); - Ok(id) + Ok((id, workspace)) }); match result { - Ok(deleted_id) => { + Ok((deleted_id, workspace)) => { ctx.search_cache.invalidate_for_memory(deleted_id); - if let Some(ref manager) = ctx.realtime { - manager.broadcast(RealtimeEvent::memory_deleted(deleted_id)); + if let (Some(manager), Some(workspace)) = (&ctx.realtime, workspace.as_deref()) { + manager.broadcast(RealtimeEvent::memory_deleted(deleted_id, workspace)); } json!({"deleted": deleted_id}) } diff --git a/src/mcp/http_transport/mod.rs b/src/mcp/http_transport/mod.rs index 97369f2c..fceb2d16 100644 --- a/src/mcp/http_transport/mod.rs +++ b/src/mcp/http_transport/mod.rs @@ -68,6 +68,13 @@ struct HttpTransportMetricsSnapshot { } impl HttpTransportMetrics { + fn on_mcp_preparse_unauthorized(&self) { + self.mcp_requests_total.fetch_add(1, Ordering::Relaxed); + self.mcp_requests_completed.fetch_add(1, Ordering::Relaxed); + self.mcp_unauthorized_total.fetch_add(1, Ordering::Relaxed); + self.mcp_failed_total.fetch_add(1, Ordering::Relaxed); + } + fn on_mcp_request_start(&self, is_notification: bool) { self.mcp_requests_total.fetch_add(1, Ordering::Relaxed); if is_notification { diff --git a/src/mcp/http_transport/router.rs b/src/mcp/http_transport/router.rs index 7f62d220..54044b85 100644 --- a/src/mcp/http_transport/router.rs +++ b/src/mcp/http_transport/router.rs @@ -4,7 +4,9 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; -use axum::http::HeaderValue; +use axum::extract::{DefaultBodyLimit, Request}; +use axum::http::{HeaderValue, StatusCode}; +use axum::middleware::{self, Next}; use axum::{ extract::State, response::IntoResponse, @@ -21,7 +23,7 @@ use super::rate_limit::{ RateLimiterConfig, RateLimiterState, RATE_LIMIT_MAX_BUCKETS, RATE_LIMIT_STALE_AFTER_SECS, }; use super::security_config::HttpSecurityConfig; -use super::{normalize_api_key, AppState, HttpTransportMetrics}; +use super::{authenticate_transport_principal, normalize_api_key, AppState, HttpTransportMetrics}; use crate::realtime::RealtimeManager; /// `GET /health` -- lightweight liveness / readiness probe. @@ -120,7 +122,8 @@ pub(super) fn build_router( http_rate_limit_rps: u64, http_rate_limit_burst: u64, http_rate_limit_key: Option, -) -> Router { + security: Option, +) -> Result { let api_key = normalize_api_key(api_key); let rate_limiter = if http_rate_limit_rps > 0 && http_rate_limit_burst > 0 { let key_header = http_rate_limit_key.and_then(|value| { @@ -145,24 +148,129 @@ pub(super) fn build_router( None }; + let security = match security { + Some(security) => security, + None => HttpSecurityConfig::from_env()?, + }; let state = AppState { handler, api_key, realtime, rate_limiter, metrics: Arc::new(HttpTransportMetrics::default()), - security: HttpSecurityConfig::from_env(), + security, }; let cors = build_cors_layer(); - Router::new() + let mcp_routes = Router::new() .route("/mcp", post(handle_mcp)) .route("/v1/mcp", post(handle_mcp)) - .route("/health", get(handle_health)) + .layer(DefaultBodyLimit::max(state.security.max_body_bytes)) + // Authentication remains outside body collection/parsing. The timeout + // added below is outermost so it covers authentication as well. + .layer(middleware::from_fn_with_state( + state.clone(), + enforce_auth_mcp, + )) + .layer(middleware::from_fn_with_state( + state.clone(), + enforce_request_timeout, + )); + + let event_routes = Router::new() .route("/v1/events", get(handle_events)) + .layer(middleware::from_fn_with_state( + state.clone(), + enforce_auth_sse, + )) + .layer(middleware::from_fn_with_state( + state.clone(), + enforce_request_timeout, + )); + + Ok(Router::new() + .merge(mcp_routes) + .merge(event_routes) + .route("/health", get(handle_health)) .layer(cors) - .with_state(state) + .with_state(state)) +} + +/// Shared principal gate; MCP and SSE only differ in unauthorized response shape. +async fn enforce_auth( + state: AppState, + request: Request, + unauthorized: impl FnOnce(&AppState) -> axum::response::Response, + next: Next, +) -> axum::response::Response { + if authenticate_transport_principal(&state.api_key, request.headers()).is_err() { + return unauthorized(&state); + } + next.run(request).await +} + +async fn enforce_auth_mcp( + State(state): State, + request: Request, + next: Next, +) -> axum::response::Response { + enforce_auth( + state, + request, + |state| { + state.metrics.on_mcp_preparse_unauthorized(); + ( + StatusCode::UNAUTHORIZED, + Json(super::super::protocol::McpResponse::error( + None, + -32001, + "Unauthorized".to_string(), + )), + ) + .into_response() + }, + next, + ) + .await +} + +async fn enforce_auth_sse( + State(state): State, + request: Request, + next: Next, +) -> axum::response::Response { + enforce_auth( + state, + request, + |state| { + state.metrics.on_events_request(true, false); + StatusCode::UNAUTHORIZED.into_response() + }, + next, + ) + .await +} + +async fn enforce_request_timeout( + State(state): State, + request: Request, + next: Next, +) -> axum::response::Response { + let is_mcp = request.uri().path() == "/mcp" || request.uri().path() == "/v1/mcp"; + match tokio::time::timeout(state.security.request_timeout, next.run(request)).await { + Ok(response) => response, + Err(_) if is_mcp => ( + StatusCode::REQUEST_TIMEOUT, + Json(super::super::protocol::McpResponse::error( + None, + -32008, + "Request Timeout".to_string(), + )), + ) + .into_response(), + Err(_) => StatusCode::REQUEST_TIMEOUT.into_response(), + } } // --------------------------------------------------------------------------- @@ -200,7 +308,9 @@ pub async fn serve_http( http_rate_limit_rps, http_rate_limit_burst, http_rate_limit_key, - ); + None, + ) + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!("HTTP transport listening on {}", addr); diff --git a/src/mcp/http_transport/security_config.rs b/src/mcp/http_transport/security_config.rs index f84f1a2a..c0cdc291 100644 --- a/src/mcp/http_transport/security_config.rs +++ b/src/mcp/http_transport/security_config.rs @@ -3,20 +3,57 @@ use std::net::{IpAddr, SocketAddr}; use axum::http::HeaderMap; const TRUSTED_PROXIES_ENV: &str = "ENGRAM_HTTP_TRUSTED_PROXIES"; +pub(super) const MAX_BODY_BYTES_ENV: &str = "ENGRAM_HTTP_MAX_BODY_BYTES"; +pub(super) const REQUEST_TIMEOUT_MS_ENV: &str = "ENGRAM_HTTP_REQUEST_TIMEOUT_MS"; +pub(super) const DEFAULT_MAX_BODY_BYTES: usize = 1_048_576; +pub(super) const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; const MAX_FORWARDED_CHAIN: usize = 32; -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub(super) struct HttpSecurityConfig { trusted_proxies: Vec, + pub(super) max_body_bytes: usize, + pub(super) request_timeout: std::time::Duration, +} + +impl Default for HttpSecurityConfig { + fn default() -> Self { + Self { + trusted_proxies: Vec::new(), + max_body_bytes: DEFAULT_MAX_BODY_BYTES, + request_timeout: std::time::Duration::from_millis(DEFAULT_REQUEST_TIMEOUT_MS), + } + } } impl HttpSecurityConfig { - pub(super) fn from_env() -> Self { + /// Load HTTP resource limits from the environment. + /// + /// Missing variables keep documented safe defaults. Present but invalid, + /// zero, or over-maximum values fail closed (same policy as WebSocket + /// resource configuration). + pub(super) fn from_env() -> Result { let trusted_proxies = std::env::var(TRUSTED_PROXIES_ENV) .ok() .map(|value| Self::parse_trusted_proxies(&value)) .unwrap_or_default(); - Self { trusted_proxies } + let max_body_bytes = parse_positive_env( + MAX_BODY_BYTES_ENV, + DEFAULT_MAX_BODY_BYTES, + 0, + DEFAULT_MAX_BODY_BYTES.saturating_mul(64), + )?; + let request_timeout_ms = parse_positive_env( + REQUEST_TIMEOUT_MS_ENV, + DEFAULT_REQUEST_TIMEOUT_MS, + 0, + 3_600_000, + )?; + Ok(Self { + trusted_proxies, + max_body_bytes, + request_timeout: std::time::Duration::from_millis(request_timeout_ms), + }) } fn parse_trusted_proxies(value: &str) -> Vec { @@ -76,6 +113,58 @@ impl HttpSecurityConfig { } } +fn parse_positive_env(name: &str, default: T, zero: T, maximum: T) -> Result +where + T: Copy + std::fmt::Display + std::str::FromStr + PartialEq + PartialOrd + Default, + ::Err: std::fmt::Display, +{ + let value = match std::env::var(name) { + Ok(value) => value, + Err(_) => return Ok(default), + }; + parse_positive_value(name, &value, zero, maximum) +} + +fn parse_positive_value(name: &str, value: &str, zero: T, maximum: T) -> Result +where + T: Copy + std::fmt::Display + std::str::FromStr + PartialEq + PartialOrd + Default, + ::Err: std::fmt::Display, +{ + let parsed = value + .parse::() + .map_err(|error| format!("{name} must be a positive integer: {error}"))?; + if parsed == T::default() || parsed <= zero { + return Err(format!("{name} must be greater than zero")); + } + if parsed > maximum { + return Err(format!("{name} must not exceed {maximum}")); + } + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_bounded() { + let config = HttpSecurityConfig::default(); + assert_eq!(config.max_body_bytes, DEFAULT_MAX_BODY_BYTES); + assert_eq!( + config.request_timeout, + std::time::Duration::from_millis(DEFAULT_REQUEST_TIMEOUT_MS) + ); + } + + #[test] + fn zero_and_invalid_values_fail_closed() { + assert!(parse_positive_value("TEST", "0", 0_usize, 64).is_err()); + assert!(parse_positive_value("TEST", "unbounded", 0_u64, 100).is_err()); + assert!(parse_positive_value("TEST", "65", 0_usize, 64).is_err()); + assert_eq!(parse_positive_value("TEST", "32", 0_usize, 64).unwrap(), 32); + } +} + #[derive(Clone, Copy, Debug)] struct IpCidr { network: IpAddr, diff --git a/src/mcp/http_transport/tests/events.rs b/src/mcp/http_transport/tests/events.rs index 68377b62..a2aa9778 100644 --- a/src/mcp/http_transport/tests/events.rs +++ b/src/mcp/http_transport/tests/events.rs @@ -9,7 +9,7 @@ use crate::realtime::{EventType, RealtimeEvent}; #[test] fn test_sse_event_serialization() { - let event = RealtimeEvent::memory_created(42, "hello world".to_string()); + let event = RealtimeEvent::memory_created(42, "hello world".to_string(), "default"); let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("\"type\":\"memory_created\"")); assert!(json.contains("\"memory_id\":42")); @@ -135,8 +135,8 @@ fn test_event_type_filter_matches() { memory_ids: None, tags: None, }; - let created = RealtimeEvent::memory_created(1, "test".to_string()); - let deleted = RealtimeEvent::memory_deleted(1); + let created = RealtimeEvent::memory_created(1, "test".to_string(), "default"); + let deleted = RealtimeEvent::memory_deleted(1, "default"); assert!(filter.matches(&created)); assert!(!filter.matches(&deleted)); } @@ -205,7 +205,11 @@ fn test_realtime_event_to_sse_with_seq_id() { let manager = RealtimeManager::new(); let _rx = manager.subscribe(); - manager.broadcast(RealtimeEvent::memory_created(1, "hello".to_string())); + manager.broadcast(RealtimeEvent::memory_created( + 1, + "hello".to_string(), + "default", + )); let buffered = manager.get_events_after(0); assert_eq!(buffered.len(), 1); @@ -223,7 +227,7 @@ fn test_realtime_event_to_sse_with_seq_id() { #[test] fn test_realtime_event_to_sse_without_seq_id_no_id_field() { // Events with seq_id = None should still build an SSE event (no id field). - let event = RealtimeEvent::memory_created(5, "no id".to_string()); + let event = RealtimeEvent::memory_created(5, "no id".to_string(), "default"); assert!(event.seq_id.is_none()); let sse = realtime_event_to_sse(&event); let _ = sse; // should not panic @@ -240,7 +244,11 @@ fn test_replay_events_after_last_id() { // Broadcast 5 events for i in 1..=5i64 { - manager.broadcast(RealtimeEvent::memory_created(i, format!("ev{i}"))); + manager.broadcast(RealtimeEvent::memory_created( + i, + format!("ev{i}"), + "default", + )); } // Simulate Last-Event-Id: 3 — client missed events 4 and 5 diff --git a/src/mcp/http_transport/tests/support.rs b/src/mcp/http_transport/tests/support.rs index 3b2aa575..0690f3cb 100644 --- a/src/mcp/http_transport/tests/support.rs +++ b/src/mcp/http_transport/tests/support.rs @@ -12,6 +12,7 @@ use serde_json::json; use super::super::rate_limit::{RateLimiterConfig, RateLimiterState}; use super::super::router::build_router; +use super::super::security_config::HttpSecurityConfig; use crate::mcp::protocol::{McpHandler, McpRequest, McpResponse}; // Serialize CORS env var mutation in tests via a shared mutex. @@ -142,7 +143,9 @@ pub(super) fn test_app_with_rate_limits( http_rate_limit_rps, http_rate_limit_burst, http_rate_limit_key.map(str::to_string), + Some(HttpSecurityConfig::default()), ) + .expect("test router") } pub(super) fn test_app_with_handler( @@ -159,7 +162,9 @@ pub(super) fn test_app_with_handler( http_rate_limit_rps, http_rate_limit_burst, None, + Some(HttpSecurityConfig::default()), ) + .expect("test router") } pub(super) fn test_rate_limiter_state( diff --git a/src/realtime/auth.rs b/src/realtime/auth.rs new file mode 100644 index 00000000..f3e3bc13 --- /dev/null +++ b/src/realtime/auth.rs @@ -0,0 +1,28 @@ +//! WebSocket principal extraction and subscription authorization. + +use axum::http::{header::AUTHORIZATION, HeaderMap}; + +use crate::auth::{TransportPrincipal, TransportPrincipalError}; + +pub(super) fn principal_can_subscribe(principal: &TransportPrincipal, workspace: &str) -> bool { + principal.allows_workspace(Some(workspace)) + && principal.has_permission( + crate::auth::Permission::Read, + crate::auth::ResourceType::Memory, + ) +} + +pub(super) fn websocket_principal( + headers: &HeaderMap, + auth_key: Option<&str>, +) -> Result { + match auth_key { + Some(expected) => { + let authorization = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()); + TransportPrincipal::from_process_bearer(authorization, expected) + } + None => Ok(TransportPrincipal::anonymous_loopback()), + } +} diff --git a/src/realtime/config.rs b/src/realtime/config.rs new file mode 100644 index 00000000..75637f7a --- /dev/null +++ b/src/realtime/config.rs @@ -0,0 +1,124 @@ +//! Bounded WebSocket resource configuration. + +use std::time::Duration; + +/// Maximum simultaneous upgraded WebSocket connections. +pub(super) const MAX_CONNECTIONS_ENV: &str = "ENGRAM_WS_MAX_CONNECTIONS"; +/// Maximum bytes accepted in one inbound WebSocket message or frame. +pub(super) const MAX_MESSAGE_BYTES_ENV: &str = "ENGRAM_WS_MAX_MESSAGE_BYTES"; +/// Maximum time an upgraded client may remain silent between inbound frames. +pub(super) const READ_IDLE_TIMEOUT_ENV: &str = "ENGRAM_WS_READ_IDLE_TIMEOUT_SECONDS"; + +const DEFAULT_MAX_CONNECTIONS: usize = 128; +const DEFAULT_MAX_MESSAGE_BYTES: usize = 64 * 1024; +const DEFAULT_READ_IDLE_TIMEOUT_SECONDS: u64 = 60; +const MAX_CONFIGURED_CONNECTIONS: usize = 10_000; +const MAX_CONFIGURED_MESSAGE_BYTES: usize = 16 * 1024 * 1024; +const MAX_CONFIGURED_IDLE_SECONDS: u64 = 3_600; + +/// Fail-closed limits used by the realtime transport. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct RealtimeConfig { + pub(super) max_connections: usize, + pub(super) max_message_bytes: usize, + pub(super) read_idle_timeout: Duration, +} + +impl Default for RealtimeConfig { + fn default() -> Self { + Self { + max_connections: DEFAULT_MAX_CONNECTIONS, + max_message_bytes: DEFAULT_MAX_MESSAGE_BYTES, + read_idle_timeout: Duration::from_secs(DEFAULT_READ_IDLE_TIMEOUT_SECONDS), + } + } +} + +impl RealtimeConfig { + pub(super) fn from_env() -> Result { + Self::from_values(|name| std::env::var(name).ok()) + } + + fn from_values(mut get: impl FnMut(&str) -> Option) -> Result { + Ok(Self { + max_connections: parse_nonzero( + MAX_CONNECTIONS_ENV, + get(MAX_CONNECTIONS_ENV), + DEFAULT_MAX_CONNECTIONS, + MAX_CONFIGURED_CONNECTIONS, + )?, + max_message_bytes: parse_nonzero( + MAX_MESSAGE_BYTES_ENV, + get(MAX_MESSAGE_BYTES_ENV), + DEFAULT_MAX_MESSAGE_BYTES, + MAX_CONFIGURED_MESSAGE_BYTES, + )?, + read_idle_timeout: Duration::from_secs(parse_nonzero( + READ_IDLE_TIMEOUT_ENV, + get(READ_IDLE_TIMEOUT_ENV), + DEFAULT_READ_IDLE_TIMEOUT_SECONDS, + MAX_CONFIGURED_IDLE_SECONDS, + )?), + }) + } +} + +fn parse_nonzero(name: &str, raw: Option, default: T, maximum: T) -> Result +where + T: std::fmt::Display + std::str::FromStr + PartialEq + PartialOrd + Default, +{ + let Some(raw) = raw else { + return Ok(default); + }; + let parsed = raw + .parse::() + .map_err(|_| format!("{name} must be a positive integer"))?; + if parsed == T::default() { + return Err(format!("{name} must be greater than zero")); + } + if parsed > maximum { + return Err(format!("{name} must not exceed {maximum}")); + } + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn defaults_are_safe_and_bounded() { + let config = RealtimeConfig::default(); + assert_eq!(config.max_connections, 128); + assert_eq!(config.max_message_bytes, 64 * 1024); + assert_eq!(config.read_idle_timeout, Duration::from_secs(60)); + } + + #[test] + fn overrides_parse_and_zero_or_invalid_values_fail_closed() { + let values = HashMap::from([ + (MAX_CONNECTIONS_ENV, "4".to_string()), + (MAX_MESSAGE_BYTES_ENV, "1024".to_string()), + (READ_IDLE_TIMEOUT_ENV, "5".to_string()), + ]); + let config = RealtimeConfig::from_values(|name| values.get(name).cloned()).unwrap(); + assert_eq!(config.max_connections, 4); + assert_eq!(config.max_message_bytes, 1024); + assert_eq!(config.read_idle_timeout, Duration::from_secs(5)); + + for (name, value) in [ + (MAX_CONNECTIONS_ENV, "0"), + (MAX_MESSAGE_BYTES_ENV, "not-a-number"), + (READ_IDLE_TIMEOUT_ENV, "0"), + (MAX_CONNECTIONS_ENV, "10001"), + (MAX_MESSAGE_BYTES_ENV, "16777217"), + (READ_IDLE_TIMEOUT_ENV, "3601"), + ] { + assert!(RealtimeConfig::from_values(|candidate| { + (candidate == name).then(|| value.to_string()) + }) + .is_err()); + } + } +} diff --git a/src/realtime/events.rs b/src/realtime/events.rs index e797a911..af71226c 100644 --- a/src/realtime/events.rs +++ b/src/realtime/events.rs @@ -42,8 +42,8 @@ pub struct RealtimeEvent { } impl RealtimeEvent { - /// Create a memory created event - pub fn memory_created(id: MemoryId, preview: String) -> Self { + /// Create a memory created event with an authoritative workspace. + pub fn memory_created(id: MemoryId, preview: String, workspace: impl Into) -> Self { Self { seq_id: None, event_type: EventType::MemoryCreated, @@ -51,12 +51,16 @@ impl RealtimeEvent { memory_id: Some(id), preview: Some(truncate(&preview, 100)), changes: None, - data: None, + data: Some(serde_json::json!({ "workspace": workspace.into() })), } } - /// Create a memory updated event - pub fn memory_updated(id: MemoryId, changes: Vec) -> Self { + /// Create a memory updated event with an authoritative workspace. + pub fn memory_updated( + id: MemoryId, + changes: Vec, + workspace: impl Into, + ) -> Self { Self { seq_id: None, event_type: EventType::MemoryUpdated, @@ -64,12 +68,12 @@ impl RealtimeEvent { memory_id: Some(id), preview: None, changes: Some(changes), - data: None, + data: Some(serde_json::json!({ "workspace": workspace.into() })), } } - /// Create a memory deleted event - pub fn memory_deleted(id: MemoryId) -> Self { + /// Create a memory deleted event with an authoritative workspace. + pub fn memory_deleted(id: MemoryId, workspace: impl Into) -> Self { Self { seq_id: None, event_type: EventType::MemoryDeleted, @@ -77,7 +81,7 @@ impl RealtimeEvent { memory_id: Some(id), preview: None, changes: None, - data: None, + data: Some(serde_json::json!({ "workspace": workspace.into() })), } } @@ -111,6 +115,14 @@ impl RealtimeEvent { })), } } + + /// Return the authoritative event workspace, when the producer supplied one. + pub fn workspace(&self) -> Option<&str> { + self.data + .as_ref() + .and_then(|data| data.get("workspace")) + .and_then(serde_json::Value::as_str) + } } /// Truncate string for preview (UTF-8 safe) diff --git a/src/realtime/metrics.rs b/src/realtime/metrics.rs new file mode 100644 index 00000000..7caf0567 --- /dev/null +++ b/src/realtime/metrics.rs @@ -0,0 +1,62 @@ +//! Aggregate realtime resource counters (no PII dimensions). + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +/// Aggregate resource counters. They intentionally contain no principal, +/// credential, workspace, or message data. +#[derive(Default)] +pub(super) struct RealtimeMetrics { + pub(super) active_connections: AtomicU64, + pub(super) accepted_connections: AtomicU64, + pub(super) connection_cap_rejections: AtomicU64, + pub(super) oversized_messages: AtomicU64, + pub(super) idle_disconnects: AtomicU64, + pub(super) completed_disconnects: AtomicU64, +} + +#[derive(Debug, serde::Serialize)] +pub(super) struct RealtimeMetricsSnapshot { + pub(super) active_connections: u64, + pub(super) accepted_connections: u64, + pub(super) connection_cap_rejections: u64, + pub(super) oversized_messages: u64, + pub(super) idle_disconnects: u64, + pub(super) completed_disconnects: u64, +} + +impl RealtimeMetrics { + pub(super) fn snapshot(&self) -> RealtimeMetricsSnapshot { + RealtimeMetricsSnapshot { + active_connections: self.active_connections.load(Ordering::Relaxed), + accepted_connections: self.accepted_connections.load(Ordering::Relaxed), + connection_cap_rejections: self.connection_cap_rejections.load(Ordering::Relaxed), + oversized_messages: self.oversized_messages.load(Ordering::Relaxed), + idle_disconnects: self.idle_disconnects.load(Ordering::Relaxed), + completed_disconnects: self.completed_disconnects.load(Ordering::Relaxed), + } + } +} + +pub(super) struct ActiveConnectionGuard { + metrics: Arc, +} + +impl ActiveConnectionGuard { + pub(super) fn new(metrics: Arc) -> Self { + metrics.active_connections.fetch_add(1, Ordering::Relaxed); + metrics.accepted_connections.fetch_add(1, Ordering::Relaxed); + Self { metrics } + } +} + +impl Drop for ActiveConnectionGuard { + fn drop(&mut self) { + self.metrics + .active_connections + .fetch_sub(1, Ordering::Relaxed); + self.metrics + .completed_disconnects + .fetch_add(1, Ordering::Relaxed); + } +} diff --git a/src/realtime/mod.rs b/src/realtime/mod.rs index 6fc09a1e..0e69c1eb 100644 --- a/src/realtime/mod.rs +++ b/src/realtime/mod.rs @@ -2,8 +2,13 @@ //! //! Provides push notifications for memory changes to connected clients. +mod auth; +mod config; pub(crate) mod events; +mod metrics; +mod origin; mod server; +mod socket; pub use events::{EventType, RealtimeEvent, SubscriptionFilter}; pub use server::{RealtimeManager, RealtimeServer}; diff --git a/src/realtime/origin.rs b/src/realtime/origin.rs new file mode 100644 index 00000000..ad709f63 --- /dev/null +++ b/src/realtime/origin.rs @@ -0,0 +1,65 @@ +//! WebSocket Origin allowlist policy. + +use std::collections::HashSet; + +use axum::http::{header::ORIGIN, HeaderMap, Uri}; + +pub(super) const WS_ALLOWED_ORIGINS_ENV: &str = "ENGRAM_WS_ALLOWED_ORIGINS"; + +pub(super) fn origin_is_allowed(headers: &HeaderMap, allowed_origins: &HashSet) -> bool { + let mut origins = headers.get_all(ORIGIN).iter(); + let Some(origin) = origins.next() else { + return true; + }; + if origins.next().is_some() { + return false; + } + let Ok(origin) = origin.to_str() else { + return false; + }; + allowed_origins.contains(origin) +} + +pub(super) fn parse_origin_allowlist(raw: Option<&str>) -> Result, String> { + let Some(raw) = raw else { + return Ok(HashSet::new()); + }; + + let mut origins = HashSet::new(); + for entry in raw.split(',') { + let origin = entry.trim(); + if origin.is_empty() || origin == "*" { + return Err(format!( + "{WS_ALLOWED_ORIGINS_ENV} must contain explicit origins without wildcards" + )); + } + let uri = origin + .parse::() + .map_err(|_| format!("{WS_ALLOWED_ORIGINS_ENV} contains an invalid origin"))?; + if !matches!(uri.scheme_str(), Some("http" | "https")) + || uri + .authority() + .is_none_or(|authority| authority.as_str().contains('@')) + || uri.query().is_some() + || !matches!(uri.path(), "" | "/") + { + return Err(format!( + "{WS_ALLOWED_ORIGINS_ENV} origins must be scheme-and-authority values" + )); + } + origins.insert(origin.trim_end_matches('/').to_string()); + } + Ok(origins) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_wildcard_and_accepts_explicit_origins() { + assert!(parse_origin_allowlist(Some("*")).is_err()); + let set = parse_origin_allowlist(Some("https://app.example")).unwrap(); + assert!(set.contains("https://app.example")); + } +} diff --git a/src/realtime/server.rs b/src/realtime/server.rs index 22b8338b..15485684 100644 --- a/src/realtime/server.rs +++ b/src/realtime/server.rs @@ -1,28 +1,29 @@ //! WebSocket server for real-time updates -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use axum::{ - extract::{ - ws::{Message, WebSocket, WebSocketUpgrade}, - State, - }, - http::{header::AUTHORIZATION, HeaderMap, StatusCode}, + extract::{ws::WebSocketUpgrade, Query, State}, + http::{HeaderMap, StatusCode}, response::IntoResponse, routing::get, Router, }; -use futures::{SinkExt, StreamExt}; use parking_lot::RwLock; -use tokio::sync::broadcast; -use uuid::Uuid; +use tokio::sync::{broadcast, Semaphore}; -use crate::auth::{TransportPrincipal, TransportPrincipalError}; +use crate::auth::TransportPrincipal; +use crate::types::normalize_workspace; +use super::auth::{principal_can_subscribe, websocket_principal}; +use super::config::RealtimeConfig; use super::events::{RealtimeEvent, SubscriptionFilter}; +use super::metrics::RealtimeMetrics; +use super::origin::{origin_is_allowed, parse_origin_allowlist, WS_ALLOWED_ORIGINS_ENV}; +use super::socket::handle_socket; /// Connection ID pub type ConnectionId = String; @@ -30,11 +31,24 @@ pub type ConnectionId = String; struct RealtimeClient { filter: SubscriptionFilter, principal: TransportPrincipal, + workspace: String, } /// Default maximum number of events retained in the replay ring buffer. const DEFAULT_MAX_BUFFERED_EVENTS: usize = 500; +/// Drops the client registration when the upgraded connection ends. +pub(super) struct RegisteredClientGuard { + pub(super) manager: RealtimeManager, + pub(super) connection_id: ConnectionId, +} + +impl Drop for RegisteredClientGuard { + fn drop(&mut self) { + self.manager.unregister_client(&self.connection_id); + } +} + /// Manages WebSocket connections and SSE subscriptions. /// /// Each event broadcast through [`RealtimeManager::broadcast`] is: @@ -141,9 +155,28 @@ impl RealtimeManager { filter: SubscriptionFilter, principal: TransportPrincipal, ) { - self.clients - .write() - .insert(id, RealtimeClient { filter, principal }); + self.try_register_client_with_principal(id, filter, principal, "default".to_string()); + } + + pub(super) fn try_register_client_with_principal( + &self, + id: ConnectionId, + filter: SubscriptionFilter, + principal: TransportPrincipal, + workspace: String, + ) -> bool { + if !principal_can_subscribe(&principal, &workspace) { + return false; + } + self.clients.write().insert( + id, + RealtimeClient { + filter, + principal, + workspace, + }, + ); + true } /// Unregister a client @@ -173,6 +206,12 @@ impl RealtimeManager { .get(id) .map(|client| client.principal.clone()) } + + pub(super) fn client_matches_event(&self, id: &str, event: &RealtimeEvent) -> bool { + self.clients.read().get(id).is_some_and(|client| { + event.workspace() == Some(client.workspace.as_str()) && client.filter.matches(event) + }) + } } impl Default for RealtimeManager { @@ -198,12 +237,23 @@ pub struct RealtimeServer { manager: RealtimeManager, addr: SocketAddr, auth_key: Option, + allowed_origins: Result, String>, + config: Result, } #[derive(Clone)] struct RealtimeServerState { manager: RealtimeManager, auth_key: Option, + allowed_origins: Arc>, + config: RealtimeConfig, + connection_slots: Arc, + metrics: Arc, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct WebSocketQuery { + workspace: Option, } impl RealtimeServer { @@ -213,6 +263,10 @@ impl RealtimeServer { manager, addr, auth_key: None, + allowed_origins: parse_origin_allowlist( + std::env::var(WS_ALLOWED_ORIGINS_ENV).ok().as_deref(), + ), + config: RealtimeConfig::from_env(), } } @@ -228,7 +282,38 @@ impl RealtimeServer { } fn router_with_auth(manager: RealtimeManager, auth_key: Option) -> Router { - let state = RealtimeServerState { manager, auth_key }; + Self::router_with_security(manager, auth_key, HashSet::new()) + } + + fn router_with_security( + manager: RealtimeManager, + auth_key: Option, + allowed_origins: HashSet, + ) -> Router { + Self::router_with_resources( + manager, + auth_key, + allowed_origins, + RealtimeConfig::default(), + Arc::new(RealtimeMetrics::default()), + ) + } + + fn router_with_resources( + manager: RealtimeManager, + auth_key: Option, + allowed_origins: HashSet, + config: RealtimeConfig, + metrics: Arc, + ) -> Router { + let state = RealtimeServerState { + manager, + auth_key, + allowed_origins: Arc::new(allowed_origins), + connection_slots: Arc::new(Semaphore::new(config.max_connections)), + config, + metrics, + }; Router::new() .route("/ws", get(ws_handler)) .route("/health", get(health_handler)) @@ -245,7 +330,19 @@ impl RealtimeServer { message, )); } - let app = Self::router_with_auth(self.manager, self.auth_key); + let allowed_origins = self + .allowed_origins + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + let config = self + .config + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + let app = Self::router_with_resources( + self.manager, + self.auth_key, + allowed_origins, + config, + Arc::new(RealtimeMetrics::default()), + ); tracing::info!("WebSocket server listening on {}", self.addr); @@ -261,6 +358,7 @@ async fn health_handler(State(state): State) -> impl IntoRe serde_json::json!({ "status": "ok", "clients": state.manager.client_count(), + "resources": state.metrics.snapshot(), }) .to_string() } @@ -269,6 +367,7 @@ async fn health_handler(State(state): State) -> impl IntoRe async fn ws_handler( ws: WebSocketUpgrade, headers: HeaderMap, + Query(query): Query, State(state): State, ) -> axum::response::Response { let principal = match websocket_principal(&headers, state.auth_key.as_deref()) { @@ -282,432 +381,62 @@ async fn ws_handler( return StatusCode::UNAUTHORIZED.into_response(); } }; - ws.on_upgrade(move |socket| handle_socket(socket, state.manager, principal)) - .into_response() -} -fn websocket_principal( - headers: &HeaderMap, - auth_key: Option<&str>, -) -> Result { - match auth_key { - Some(expected) => { - let authorization = headers - .get(AUTHORIZATION) - .and_then(|value| value.to_str().ok()); - TransportPrincipal::from_process_bearer(authorization, expected) - } - None => Ok(TransportPrincipal::anonymous_loopback()), + if !origin_is_allowed(&headers, &state.allowed_origins) { + tracing::warn!(target = "engram::realtime", "websocket origin rejected"); + return StatusCode::FORBIDDEN.into_response(); } -} -/// Handle an individual WebSocket connection -async fn handle_socket(socket: WebSocket, manager: RealtimeManager, principal: TransportPrincipal) { - let connection_id = Uuid::new_v4().to_string(); - let filter = SubscriptionFilter::default(); - - manager.register_client_with_principal(connection_id.clone(), filter.clone(), principal); - tracing::info!("Client connected: {}", connection_id); - - let (mut sender, mut receiver) = socket.split(); - let mut rx = manager.subscribe(); - - // Task to forward events to client - let conn_id = connection_id.clone(); - let mgr = manager.clone(); - let send_task = AbortOnDropTask::new(tokio::spawn(async move { - while let Ok(event) = rx.recv().await { - // Check if event matches client's filter - if let Some(filter) = mgr.get_client_filter(&conn_id) { - if filter.matches(&event) { - let json = serde_json::to_string(&event).unwrap_or_default(); - if sender.send(Message::Text(json)).await.is_err() { - break; - } - } - } - } - })); - - // Task to handle incoming messages from client - let conn_id = connection_id.clone(); - let mgr = manager.clone(); - let recv_task = AbortOnDropTask::new(tokio::spawn(async move { - while let Some(Ok(msg)) = receiver.next().await { - match msg { - Message::Text(text) => { - // Try to parse as filter update - if let Ok(new_filter) = serde_json::from_str::(&text) { - mgr.update_client_filter(&conn_id, new_filter); - tracing::debug!("Updated filter for client {}", conn_id); - } - } - Message::Close(_) => { - break; - } - _ => {} - } + let requested_workspace = match query.workspace.as_deref() { + Some(raw) => match normalize_workspace(raw) { + Ok(workspace) => workspace, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }, + None => "default".to_string(), + }; + if !principal_can_subscribe(&principal, &requested_workspace) { + tracing::warn!(target = "engram::realtime", "websocket workspace rejected"); + return StatusCode::FORBIDDEN.into_response(); + } + + let connection_permit = match state.connection_slots.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + state + .metrics + .connection_cap_rejections + .fetch_add(1, Ordering::Relaxed); + tracing::warn!( + target = "engram::realtime", + "websocket connection cap reached" + ); + return StatusCode::SERVICE_UNAVAILABLE.into_response(); } - })); - - await_peer_tasks(send_task, recv_task).await; - - manager.unregister_client(&connection_id); - tracing::info!("Client disconnected: {}", connection_id); -} - -struct AbortOnDropTask { - handle: tokio::task::JoinHandle<()>, -} - -impl AbortOnDropTask { - fn new(handle: tokio::task::JoinHandle<()>) -> Self { - Self { handle } - } - - async fn abort_and_wait(&mut self) { - self.handle.abort(); - let _ = (&mut self.handle).await; - } -} - -impl Drop for AbortOnDropTask { - fn drop(&mut self) { - self.handle.abort(); - } -} + }; -async fn await_peer_tasks(mut send_task: AbortOnDropTask, mut recv_task: AbortOnDropTask) { - tokio::select! { - _ = &mut send_task.handle => { - recv_task.abort_and_wait().await; - } - _ = &mut recv_task.handle => { - send_task.abort_and_wait().await; - } - } + // Axum/tungstenite rejects declared frames above max+1 before allocating + // their payload. The one-byte margin lets the application return the + // documented 1009 close for the exact boundary regression. + let transport_limit = state.config.max_message_bytes.saturating_add(1); + let config = state.config.clone(); + let metrics = state.metrics.clone(); + + ws.max_frame_size(transport_limit) + .max_message_size(transport_limit) + .on_upgrade(move |socket| { + handle_socket( + socket, + state.manager, + principal, + requested_workspace, + config, + metrics, + connection_permit, + ) + }) + .into_response() } #[cfg(test)] -mod tests { - use super::*; - use base64::Engine as _; - use rand::RngCore; - - struct TaskDropSignal(Arc); - - impl Drop for TaskDropSignal { - fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); - } - } - - #[tokio::test] - async fn disconnect_cancels_blocked_send_peer() { - // Given: a send peer blocked waiting for an event and a completed receive peer. - let send_dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let send_signal = TaskDropSignal(send_dropped.clone()); - let send_task = AbortOnDropTask::new(tokio::spawn(async move { - let _signal = send_signal; - std::future::pending::<()>().await; - })); - let recv_task = AbortOnDropTask::new(tokio::spawn(async {})); - - // When: the receive peer observes the client disconnect. - await_peer_tasks(send_task, recv_task).await; - - // Then: the blocked send peer is cancelled and awaited before cleanup returns. - assert!(send_dropped.load(Ordering::SeqCst)); - } - - #[tokio::test] - async fn send_failure_cancels_blocked_receive_peer() { - // Given: a completed send peer and a receive peer blocked waiting for client input. - let recv_dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let recv_signal = TaskDropSignal(recv_dropped.clone()); - let send_task = AbortOnDropTask::new(tokio::spawn(async {})); - let recv_task = AbortOnDropTask::new(tokio::spawn(async move { - let _signal = recv_signal; - std::future::pending::<()>().await; - })); - - // When: the send peer stops after its websocket write fails. - await_peer_tasks(send_task, recv_task).await; - - // Then: the blocked receive peer is cancelled and awaited before cleanup returns. - assert!(recv_dropped.load(Ordering::SeqCst)); - } - - #[tokio::test] - async fn coordinator_cancellation_aborts_both_peers() { - // Given: two live peer tasks owned by a connection coordinator. - let send_dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let recv_dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let send_signal = TaskDropSignal(send_dropped.clone()); - let recv_signal = TaskDropSignal(recv_dropped.clone()); - let send_task = AbortOnDropTask::new(tokio::spawn(async move { - let _signal = send_signal; - std::future::pending::<()>().await; - })); - let recv_task = AbortOnDropTask::new(tokio::spawn(async move { - let _signal = recv_signal; - std::future::pending::<()>().await; - })); - let coordinator = tokio::spawn(await_peer_tasks(send_task, recv_task)); - tokio::task::yield_now().await; - - // When: the connection coordinator is cancelled before either peer finishes. - coordinator.abort(); - let _ = coordinator.await; - for _ in 0..100 { - if send_dropped.load(Ordering::SeqCst) && recv_dropped.load(Ordering::SeqCst) { - break; - } - tokio::task::yield_now().await; - } - - // Then: ownership cancellation aborts both peer tasks instead of detaching them. - assert!(send_dropped.load(Ordering::SeqCst)); - assert!(recv_dropped.load(Ordering::SeqCst)); - } - - #[test] - fn test_realtime_manager() { - let manager = RealtimeManager::new(); - assert_eq!(manager.client_count(), 0); - - manager.register_client("test".to_string(), SubscriptionFilter::default()); - assert_eq!(manager.client_count(), 1); - - manager.unregister_client("test"); - assert_eq!(manager.client_count(), 0); - } - - #[test] - fn authenticated_principal_survives_subscription_filter_updates() { - // Given: an authenticated websocket connection registered with the manager. - let manager = RealtimeManager::new(); - let principal = - TransportPrincipal::from_process_bearer(Some("Bearer secret"), "secret").unwrap(); - manager.register_client_with_principal( - "authenticated".to_string(), - SubscriptionFilter::default(), - principal, - ); - - // When: the connected client changes its event subscription filter. - manager.update_client_filter( - "authenticated", - SubscriptionFilter { - event_types: Some(vec![super::super::events::EventType::MemoryCreated]), - memory_ids: None, - tags: None, - }, - ); - - // Then: the connection retains the authenticated process principal. - let attached = manager - .get_client_principal("authenticated") - .expect("authenticated principal remains attached"); - assert!(attached.has_permission( - crate::auth::Permission::Admin, - crate::auth::ResourceType::System - )); - } - - #[tokio::test] - async fn authenticated_handshake_attaches_process_principal() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - // Given: a real loopback websocket listener with process bearer auth. - let manager = RealtimeManager::new(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let app = RealtimeServer::router_with_auth(manager.clone(), Some("secret".to_string())); - let server = tokio::spawn(async move { axum::serve(listener, app).await }); - let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); - let mut nonce = [0_u8; 16]; - rand::rngs::OsRng.fill_bytes(&mut nonce); - let websocket_key = base64::engine::general_purpose::STANDARD.encode(nonce); - - // When: a client completes the upgrade with the configured bearer. - client - .write_all( - format!( - "GET /ws HTTP/1.1\r\nHost: {address}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: {websocket_key}\r\nAuthorization: Bearer secret\r\n\r\n" - ) - .as_bytes(), - ) - .await - .unwrap(); - let mut response = Vec::new(); - while !response.ends_with(b"\r\n\r\n") { - response.push(client.read_u8().await.unwrap()); - } - - // Then: the upgraded connection is registered with its process principal. - assert!(String::from_utf8(response) - .unwrap() - .starts_with("HTTP/1.1 101")); - for _ in 0..100 { - if manager.client_count() == 1 { - break; - } - tokio::task::yield_now().await; - } - let clients = manager.clients.read(); - let attached = &clients - .values() - .next() - .expect("upgraded client is registered") - .principal; - assert!(attached.has_permission( - crate::auth::Permission::Admin, - crate::auth::ResourceType::System - )); - - server.abort(); - } - - #[test] - fn test_subscription_filter() { - let filter = SubscriptionFilter { - event_types: Some(vec![super::super::events::EventType::MemoryCreated]), - memory_ids: None, - tags: None, - }; - - let event = RealtimeEvent::memory_created(1, "test".to_string()); - assert!(filter.matches(&event)); - - let event = RealtimeEvent::memory_deleted(1); - assert!(!filter.matches(&event)); - } - - // --- Sequential event ID tests ------------------------------------------ - - #[test] - fn test_broadcast_stamps_sequential_ids() { - let manager = RealtimeManager::new(); - let _rx = manager.subscribe(); // keep channel alive - - manager.broadcast(RealtimeEvent::memory_created(1, "first".to_string())); - manager.broadcast(RealtimeEvent::memory_created(2, "second".to_string())); - manager.broadcast(RealtimeEvent::memory_deleted(3)); - - // IDs should be 1, 2, 3 (counter starts at 1) - let buf = manager.buffer.read(); - let ids: Vec = buf.iter().filter_map(|e| e.seq_id).collect(); - assert_eq!(ids, vec![1, 2, 3]); - } - - #[test] - fn test_seq_id_starts_at_one() { - let manager = RealtimeManager::new(); - assert_eq!(manager.current_seq(), 1); - - let _rx = manager.subscribe(); - manager.broadcast(RealtimeEvent::memory_created(1, "hello".to_string())); - assert_eq!(manager.current_seq(), 2); // next id to be issued - } - - // --- Ring buffer eviction tests ----------------------------------------- - - #[test] - fn test_ring_buffer_evicts_oldest_when_full() { - let max = 3; - let manager = RealtimeManager::with_buffer_size(max); - let _rx = manager.subscribe(); - - for i in 1..=5u64 { - manager.broadcast(RealtimeEvent::memory_created(i as i64, format!("m{i}"))); - } - - let buf = manager.buffer.read(); - assert_eq!(buf.len(), max, "buffer should be at capacity"); - // The first two events (seq 1, 2) should have been evicted - let ids: Vec = buf.iter().filter_map(|e| e.seq_id).collect(); - assert_eq!(ids, vec![3, 4, 5]); - } - - #[test] - fn test_ring_buffer_does_not_exceed_max_size() { - let max = 10; - let manager = RealtimeManager::with_buffer_size(max); - let _rx = manager.subscribe(); - - for i in 1..=20u64 { - manager.broadcast(RealtimeEvent::memory_deleted(i as i64)); - } - - assert_eq!(manager.buffer.read().len(), max); - } - - // --- Replay / get_events_after tests ------------------------------------ - - #[test] - fn test_get_events_after_returns_correct_subset() { - let manager = RealtimeManager::new(); - let _rx = manager.subscribe(); - - manager.broadcast(RealtimeEvent::memory_created(1, "a".to_string())); // seq 1 - manager.broadcast(RealtimeEvent::memory_created(2, "b".to_string())); // seq 2 - manager.broadcast(RealtimeEvent::memory_deleted(3)); // seq 3 - - let replayed = manager.get_events_after(1); - assert_eq!(replayed.len(), 2); - let ids: Vec = replayed.iter().filter_map(|e| e.seq_id).collect(); - assert_eq!(ids, vec![2, 3]); - } - - #[test] - fn test_get_events_after_zero_returns_all() { - let manager = RealtimeManager::new(); - let _rx = manager.subscribe(); - - manager.broadcast(RealtimeEvent::memory_created(1, "x".to_string())); - manager.broadcast(RealtimeEvent::memory_created(2, "y".to_string())); - - let replayed = manager.get_events_after(0); - assert_eq!(replayed.len(), 2); - } - - #[test] - fn test_get_events_after_last_id_returns_empty() { - let manager = RealtimeManager::new(); - let _rx = manager.subscribe(); - - manager.broadcast(RealtimeEvent::memory_created(1, "only".to_string())); // seq 1 - - // Requesting events after the last known ID → nothing new - let replayed = manager.get_events_after(1); - assert!(replayed.is_empty()); - } - - #[test] - fn test_get_events_after_large_id_returns_empty() { - let manager = RealtimeManager::new(); - let _rx = manager.subscribe(); - - manager.broadcast(RealtimeEvent::memory_created(1, "ev".to_string())); - - let replayed = manager.get_events_after(9999); - assert!(replayed.is_empty()); - } - - // --- Clone shares same state -------------------------------------------- - - #[test] - fn test_clone_shares_buffer() { - let manager = RealtimeManager::new(); - let cloned = manager.clone(); - let _rx = manager.subscribe(); - - manager.broadcast(RealtimeEvent::memory_created(1, "shared".to_string())); - - // cloned should see the same buffer - assert_eq!(cloned.buffer.read().len(), 1); - let replayed = cloned.get_events_after(0); - assert_eq!(replayed.len(), 1); - } -} +#[path = "server_tests.rs"] +mod tests; diff --git a/src/realtime/server_tests.rs b/src/realtime/server_tests.rs new file mode 100644 index 00000000..3bb1224c --- /dev/null +++ b/src/realtime/server_tests.rs @@ -0,0 +1,672 @@ +//! Unit tests for the realtime WebSocket server. + +use super::super::metrics::RealtimeMetrics; +use super::super::origin::parse_origin_allowlist; +use super::super::socket::{CLOSE_REASON_MESSAGE_TOO_LARGE, CLOSE_REASON_READ_IDLE}; +use super::*; +use axum::extract::ws::close_code; +use base64::Engine as _; +use rand::RngCore; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +async fn websocket_handshake( + manager: RealtimeManager, + path: &str, + auth_key: Option<&str>, + authorization: Option<&str>, + origin: Option<&str>, + allowed_origins: &[&str], +) -> ( + tokio::net::TcpStream, + String, + tokio::task::JoinHandle>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = RealtimeServer::router_with_security( + manager, + auth_key.map(str::to_string), + allowed_origins + .iter() + .map(|origin| (*origin).to_string()) + .collect(), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + let mut nonce = [0_u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let websocket_key = base64::engine::general_purpose::STANDARD.encode(nonce); + let authorization = authorization + .map(|value| format!("Authorization: {value}\r\n")) + .unwrap_or_default(); + let origin = origin + .map(|value| format!("Origin: {value}\r\n")) + .unwrap_or_default(); + + client + .write_all( + format!( + "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: {websocket_key}\r\n{authorization}{origin}\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = Vec::new(); + while !response.ends_with(b"\r\n\r\n") { + response.push(client.read_u8().await.unwrap()); + } + + (client, String::from_utf8(response).unwrap(), server) +} + +async fn spawn_resource_server( + manager: RealtimeManager, + auth_key: Option<&str>, + config: RealtimeConfig, + metrics: Arc, +) -> ( + SocketAddr, + tokio::task::JoinHandle>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = RealtimeServer::router_with_resources( + manager, + auth_key.map(str::to_string), + HashSet::new(), + config, + metrics, + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + (address, server) +} + +async fn websocket_handshake_to( + address: SocketAddr, + path: &str, + authorization: Option<&str>, +) -> (tokio::net::TcpStream, String) { + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + let mut nonce = [0_u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let websocket_key = base64::engine::general_purpose::STANDARD.encode(nonce); + let authorization = authorization + .map(|value| format!("Authorization: {value}\r\n")) + .unwrap_or_default(); + client + .write_all( + format!( + "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: {websocket_key}\r\n{authorization}\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = Vec::new(); + while !response.ends_with(b"\r\n\r\n") { + response.push(client.read_u8().await.unwrap()); + } + (client, String::from_utf8(response).unwrap()) +} + +async fn read_server_text(client: &mut tokio::net::TcpStream) -> String { + let first = client.read_u8().await.unwrap(); + assert_eq!(first & 0x0f, 1, "expected a text websocket frame"); + let second = client.read_u8().await.unwrap(); + assert_eq!(second & 0x80, 0, "server frames must not be masked"); + let len = match second & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => usize::from(client.read_u16().await.unwrap()), + 127 => usize::try_from(client.read_u64().await.unwrap()).unwrap(), + _ => unreachable!(), + }; + let mut payload = vec![0; len]; + client.read_exact(&mut payload).await.unwrap(); + String::from_utf8(payload).unwrap() +} + +async fn write_masked_text(client: &mut tokio::net::TcpStream, payload: &[u8]) { + assert!(payload.len() <= 125); + let mut mask = [0_u8; 4]; + rand::rngs::OsRng.fill_bytes(&mut mask); + client.write_u8(0x81).await.unwrap(); + client + .write_u8(0x80 | u8::try_from(payload.len()).unwrap()) + .await + .unwrap(); + client.write_all(&mask).await.unwrap(); + let masked = payload + .iter() + .enumerate() + .map(|(index, byte)| byte ^ mask[index % mask.len()]) + .collect::>(); + client.write_all(&masked).await.unwrap(); +} + +async fn read_server_close(client: &mut tokio::net::TcpStream) -> (u16, String) { + let first = client.read_u8().await.unwrap(); + assert_eq!(first & 0x0f, 8, "expected a websocket close frame"); + let second = client.read_u8().await.unwrap(); + assert_eq!(second & 0x80, 0, "server frames must not be masked"); + let len = usize::from(second & 0x7f); + assert!((2..=125).contains(&len)); + let code = client.read_u16().await.unwrap(); + let mut reason = vec![0; len - 2]; + client.read_exact(&mut reason).await.unwrap(); + (code, String::from_utf8(reason).unwrap()) +} + +async fn wait_for_active(metrics: &RealtimeMetrics, expected: u64) { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if metrics.active_connections.load(Ordering::Relaxed) == expected { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("active websocket gauge must converge"); +} + +#[test] +fn test_realtime_manager() { + let manager = RealtimeManager::new(); + assert_eq!(manager.client_count(), 0); + + manager.register_client("test".to_string(), SubscriptionFilter::default()); + assert_eq!(manager.client_count(), 1); + + manager.unregister_client("test"); + assert_eq!(manager.client_count(), 0); +} + +#[test] +fn authenticated_principal_survives_subscription_filter_updates() { + // Given: an authenticated websocket connection registered with the manager. + let manager = RealtimeManager::new(); + let principal = + TransportPrincipal::from_process_bearer(Some("Bearer secret"), "secret").unwrap(); + manager.register_client_with_principal( + "authenticated".to_string(), + SubscriptionFilter::default(), + principal, + ); + + // When: the connected client changes its event subscription filter. + manager.update_client_filter( + "authenticated", + SubscriptionFilter { + event_types: Some(vec![super::super::events::EventType::MemoryCreated]), + memory_ids: None, + tags: None, + }, + ); + + // Then: the connection retains the authenticated process principal. + let attached = manager + .get_client_principal("authenticated") + .expect("authenticated principal remains attached"); + assert!(attached.has_permission( + crate::auth::Permission::Admin, + crate::auth::ResourceType::System + )); +} + +#[tokio::test] +async fn authenticated_handshake_attaches_process_principal() { + // Given: a real loopback websocket listener with process bearer auth. + let manager = RealtimeManager::new(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = RealtimeServer::router_with_auth(manager.clone(), Some("secret".to_string())); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + let mut nonce = [0_u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let websocket_key = base64::engine::general_purpose::STANDARD.encode(nonce); + + // When: a client completes the upgrade with the configured bearer. + client + .write_all( + format!( + "GET /ws HTTP/1.1\r\nHost: {address}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: {websocket_key}\r\nAuthorization: Bearer secret\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut response = Vec::new(); + while !response.ends_with(b"\r\n\r\n") { + response.push(client.read_u8().await.unwrap()); + } + + // Then: the upgraded connection is registered with its process principal. + assert!(String::from_utf8(response) + .unwrap() + .starts_with("HTTP/1.1 101")); + for _ in 0..100 { + if manager.client_count() == 1 { + break; + } + tokio::task::yield_now().await; + } + let clients = manager.clients.read(); + let attached = &clients + .values() + .next() + .expect("upgraded client is registered") + .principal; + assert!(attached.has_permission( + crate::auth::Permission::Admin, + crate::auth::ResourceType::System + )); + + server.abort(); +} + +#[tokio::test] +async fn real_client_receives_only_its_authorized_workspace() { + let manager = RealtimeManager::new(); + let (mut client, response, server) = websocket_handshake( + manager.clone(), + "/ws?workspace=default", + None, + None, + None, + &[], + ) + .await; + assert!(response.starts_with("HTTP/1.1 101")); + for _ in 0..100 { + if manager.client_count() == 1 { + break; + } + tokio::task::yield_now().await; + } + + manager.broadcast(RealtimeEvent::memory_created( + 1, + "private sentinel".to_string(), + "private", + )); + manager.broadcast(RealtimeEvent::memory_created( + 2, + "visible sentinel".to_string(), + "default", + )); + + let payload = tokio::time::timeout( + std::time::Duration::from_secs(2), + read_server_text(&mut client), + ) + .await + .expect("authorized workspace event must arrive"); + assert!(payload.contains("visible sentinel")); + assert!(!payload.contains("private sentinel")); + server.abort(); +} + +#[tokio::test] +async fn anonymous_client_cannot_subscribe_to_private_workspace() { + let manager = RealtimeManager::new(); + let (_client, response, server) = websocket_handshake( + manager.clone(), + "/ws?workspace=private", + None, + None, + None, + &[], + ) + .await; + + assert!(response.starts_with("HTTP/1.1 403")); + assert_eq!(manager.client_count(), 0); + server.abort(); +} + +#[tokio::test] +async fn origin_allowlist_is_exact_and_missing_origin_is_non_browser() { + let allowed = "https://app.example.com"; + let (_client, response, server) = websocket_handshake( + RealtimeManager::new(), + "/ws", + None, + None, + Some(allowed), + &[allowed], + ) + .await; + assert!(response.starts_with("HTTP/1.1 101")); + server.abort(); + + let (_client, response, server) = websocket_handshake( + RealtimeManager::new(), + "/ws", + None, + None, + Some("https://evil.example.com"), + &[allowed], + ) + .await; + assert!(response.starts_with("HTTP/1.1 403")); + server.abort(); + + let (_client, response, server) = + websocket_handshake(RealtimeManager::new(), "/ws", None, None, None, &[allowed]).await; + assert!(response.starts_with("HTTP/1.1 101")); + server.abort(); +} + +#[tokio::test] +async fn connection_cap_rejects_cap_plus_one_and_cleanup_restores_gauge() { + let manager = RealtimeManager::new(); + let metrics = Arc::new(RealtimeMetrics::default()); + let config = RealtimeConfig { + max_connections: 1, + max_message_bytes: 1024, + read_idle_timeout: std::time::Duration::from_secs(5), + }; + let (address, server) = spawn_resource_server(manager, None, config, metrics.clone()).await; + + let (first, response) = websocket_handshake_to(address, "/ws", None).await; + assert!(response.starts_with("HTTP/1.1 101")); + wait_for_active(&metrics, 1).await; + + let (_second, response) = websocket_handshake_to(address, "/ws", None).await; + assert!(response.starts_with("HTTP/1.1 503")); + assert_eq!(metrics.connection_cap_rejections.load(Ordering::Relaxed), 1); + + drop(first); + wait_for_active(&metrics, 0).await; + let snapshot = metrics.snapshot(); + assert_eq!(snapshot.accepted_connections, 1); + assert_eq!(snapshot.completed_disconnects, 1); + server.abort(); +} + +#[tokio::test] +async fn oversized_message_closes_with_1009_without_disturbing_healthy_client() { + let manager = RealtimeManager::new(); + let metrics = Arc::new(RealtimeMetrics::default()); + let config = RealtimeConfig { + max_connections: 2, + max_message_bytes: 8, + read_idle_timeout: std::time::Duration::from_secs(5), + }; + let (address, server) = + spawn_resource_server(manager.clone(), Some("secret"), config, metrics.clone()).await; + let (mut oversized, response) = + websocket_handshake_to(address, "/ws", Some("Bearer secret")).await; + assert!(response.starts_with("HTTP/1.1 101")); + let (mut healthy, response) = + websocket_handshake_to(address, "/ws", Some("Bearer secret")).await; + assert!(response.starts_with("HTTP/1.1 101")); + wait_for_active(&metrics, 2).await; + + write_masked_text(&mut oversized, b"123456789").await; + let (code, reason) = read_server_close(&mut oversized).await; + assert_eq!(code, close_code::SIZE); + assert_eq!(reason, CLOSE_REASON_MESSAGE_TOO_LARGE); + wait_for_active(&metrics, 1).await; + assert_eq!(metrics.oversized_messages.load(Ordering::Relaxed), 1); + + manager.broadcast(RealtimeEvent::memory_created( + 7, + "healthy stream".to_string(), + "default", + )); + let payload = tokio::time::timeout( + std::time::Duration::from_secs(2), + read_server_text(&mut healthy), + ) + .await + .expect("healthy authenticated stream remains available"); + assert!(payload.contains("healthy stream")); + server.abort(); +} + +#[tokio::test] +async fn idle_client_closes_with_1008_and_records_cleanup_metrics() { + let metrics = Arc::new(RealtimeMetrics::default()); + let config = RealtimeConfig { + max_connections: 1, + max_message_bytes: 1024, + read_idle_timeout: std::time::Duration::from_millis(30), + }; + let (address, server) = + spawn_resource_server(RealtimeManager::new(), None, config, metrics.clone()).await; + let (mut client, response) = websocket_handshake_to(address, "/ws", None).await; + assert!(response.starts_with("HTTP/1.1 101")); + + let (code, reason) = tokio::time::timeout( + std::time::Duration::from_secs(2), + read_server_close(&mut client), + ) + .await + .expect("idle client must be disconnected"); + assert_eq!(code, close_code::POLICY); + assert_eq!(reason, CLOSE_REASON_READ_IDLE); + wait_for_active(&metrics, 0).await; + let snapshot = metrics.snapshot(); + assert_eq!(snapshot.idle_disconnects, 1); + assert_eq!(snapshot.completed_disconnects, 1); + server.abort(); +} + +#[test] +fn origin_allowlist_rejects_wildcards_and_non_origins() { + assert!(parse_origin_allowlist(Some("*")).is_err()); + assert!(parse_origin_allowlist(Some("app.example.com")).is_err()); + assert!(parse_origin_allowlist(Some("https://app.example.com/path")).is_err()); + assert_eq!( + parse_origin_allowlist(Some("https://app.example.com, https://admin.example.com/")) + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn scoped_principal_cannot_register_cross_workspace() { + use crate::auth::{PermissionSet, TokenClaims, UserId}; + use chrono::Utc; + + let manager = RealtimeManager::new(); + let principal = TransportPrincipal::from_token_claims(TokenClaims { + user_id: UserId::from_string("user-1"), + key_id: "key-1".to_string(), + permissions: PermissionSet::read_only(), + namespace: Some("alpha".to_string()), + issued_at: Utc::now(), + expires_at: None, + }) + .unwrap(); + assert!(manager.try_register_client_with_principal( + "scoped".to_string(), + SubscriptionFilter::default(), + principal.clone(), + "alpha".to_string(), + )); + + assert!(!manager.try_register_client_with_principal( + "cross-workspace".to_string(), + SubscriptionFilter::default(), + principal, + "beta".to_string(), + )); + assert!(manager.get_client_filter("scoped").is_some()); + assert!(manager.get_client_filter("cross-workspace").is_none()); +} + +#[test] +fn test_subscription_filter() { + let filter = SubscriptionFilter { + event_types: Some(vec![super::super::events::EventType::MemoryCreated]), + memory_ids: None, + tags: None, + }; + + let event = RealtimeEvent::memory_created(1, "test".to_string(), "default"); + assert!(filter.matches(&event)); + + let event = RealtimeEvent::memory_deleted(1, "default"); + assert!(!filter.matches(&event)); +} + +// --- Sequential event ID tests ------------------------------------------ + +#[test] +fn test_broadcast_stamps_sequential_ids() { + let manager = RealtimeManager::new(); + let _rx = manager.subscribe(); // keep channel alive + + manager.broadcast(RealtimeEvent::memory_created( + 1, + "first".to_string(), + "default", + )); + manager.broadcast(RealtimeEvent::memory_created( + 2, + "second".to_string(), + "default", + )); + manager.broadcast(RealtimeEvent::memory_deleted(3, "default")); + + // IDs should be 1, 2, 3 (counter starts at 1) + let buf = manager.buffer.read(); + let ids: Vec = buf.iter().filter_map(|e| e.seq_id).collect(); + assert_eq!(ids, vec![1, 2, 3]); +} + +#[test] +fn test_seq_id_starts_at_one() { + let manager = RealtimeManager::new(); + assert_eq!(manager.current_seq(), 1); + + let _rx = manager.subscribe(); + manager.broadcast(RealtimeEvent::memory_created( + 1, + "hello".to_string(), + "default", + )); + assert_eq!(manager.current_seq(), 2); // next id to be issued +} + +// --- Ring buffer eviction tests ----------------------------------------- + +#[test] +fn test_ring_buffer_evicts_oldest_when_full() { + let max = 3; + let manager = RealtimeManager::with_buffer_size(max); + let _rx = manager.subscribe(); + + for i in 1..=5u64 { + manager.broadcast(RealtimeEvent::memory_created( + i as i64, + format!("m{i}"), + "default", + )); + } + + let buf = manager.buffer.read(); + assert_eq!(buf.len(), max, "buffer should be at capacity"); + // The first two events (seq 1, 2) should have been evicted + let ids: Vec = buf.iter().filter_map(|e| e.seq_id).collect(); + assert_eq!(ids, vec![3, 4, 5]); +} + +#[test] +fn test_ring_buffer_does_not_exceed_max_size() { + let max = 10; + let manager = RealtimeManager::with_buffer_size(max); + let _rx = manager.subscribe(); + + for i in 1..=20u64 { + manager.broadcast(RealtimeEvent::memory_deleted(i as i64, "default")); + } + + assert_eq!(manager.buffer.read().len(), max); +} + +// --- Replay / get_events_after tests ------------------------------------ + +#[test] +fn test_get_events_after_returns_correct_subset() { + let manager = RealtimeManager::new(); + let _rx = manager.subscribe(); + + manager.broadcast(RealtimeEvent::memory_created(1, "a".to_string(), "default")); // seq 1 + manager.broadcast(RealtimeEvent::memory_created(2, "b".to_string(), "default")); // seq 2 + manager.broadcast(RealtimeEvent::memory_deleted(3, "default")); // seq 3 + + let replayed = manager.get_events_after(1); + assert_eq!(replayed.len(), 2); + let ids: Vec = replayed.iter().filter_map(|e| e.seq_id).collect(); + assert_eq!(ids, vec![2, 3]); +} + +#[test] +fn test_get_events_after_zero_returns_all() { + let manager = RealtimeManager::new(); + let _rx = manager.subscribe(); + + manager.broadcast(RealtimeEvent::memory_created(1, "x".to_string(), "default")); + manager.broadcast(RealtimeEvent::memory_created(2, "y".to_string(), "default")); + + let replayed = manager.get_events_after(0); + assert_eq!(replayed.len(), 2); +} + +#[test] +fn test_get_events_after_last_id_returns_empty() { + let manager = RealtimeManager::new(); + let _rx = manager.subscribe(); + + manager.broadcast(RealtimeEvent::memory_created( + 1, + "only".to_string(), + "default", + )); // seq 1 + + // Requesting events after the last known ID → nothing new + let replayed = manager.get_events_after(1); + assert!(replayed.is_empty()); +} + +#[test] +fn test_get_events_after_large_id_returns_empty() { + let manager = RealtimeManager::new(); + let _rx = manager.subscribe(); + + manager.broadcast(RealtimeEvent::memory_created( + 1, + "ev".to_string(), + "default", + )); + + let replayed = manager.get_events_after(9999); + assert!(replayed.is_empty()); +} + +// --- Clone shares same state -------------------------------------------- + +#[test] +fn test_clone_shares_buffer() { + let manager = RealtimeManager::new(); + let cloned = manager.clone(); + let _rx = manager.subscribe(); + + manager.broadcast(RealtimeEvent::memory_created( + 1, + "shared".to_string(), + "default", + )); + + // cloned should see the same buffer + assert_eq!(cloned.buffer.read().len(), 1); + let replayed = cloned.get_events_after(0); + assert_eq!(replayed.len(), 1); +} diff --git a/src/realtime/socket.rs b/src/realtime/socket.rs new file mode 100644 index 00000000..8f184e29 --- /dev/null +++ b/src/realtime/socket.rs @@ -0,0 +1,137 @@ +//! Per-connection WebSocket lifecycle. + +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use axum::extract::ws::{close_code, CloseFrame, Message, WebSocket}; +use tokio::sync::OwnedSemaphorePermit; +use uuid::Uuid; + +use crate::auth::TransportPrincipal; + +use super::config::RealtimeConfig; +use super::events::SubscriptionFilter; +use super::metrics::{ActiveConnectionGuard, RealtimeMetrics}; +use super::server::{RealtimeManager, RegisteredClientGuard}; + +pub(super) const CLOSE_REASON_MESSAGE_TOO_LARGE: &str = "message exceeds configured byte limit"; +pub(super) const CLOSE_REASON_READ_IDLE: &str = "read idle timeout"; +const WS_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const WS_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); + +/// Handle an individual WebSocket connection after a successful upgrade. +pub(super) async fn handle_socket( + mut socket: WebSocket, + manager: RealtimeManager, + principal: TransportPrincipal, + workspace: String, + config: RealtimeConfig, + metrics: Arc, + _connection_permit: OwnedSemaphorePermit, +) { + let connection_id = Uuid::new_v4().to_string(); + let filter = SubscriptionFilter::default(); + + if !manager.try_register_client_with_principal( + connection_id.clone(), + filter, + principal, + workspace, + ) { + tracing::warn!( + target = "engram::realtime", + "websocket subscription rejected" + ); + return; + } + let _active_connection = ActiveConnectionGuard::new(metrics.clone()); + let _registered_client = RegisteredClientGuard { + manager: manager.clone(), + connection_id: connection_id.clone(), + }; + tracing::info!("Client connected: {}", connection_id); + + let mut rx = manager.subscribe(); + let idle = tokio::time::sleep(config.read_idle_timeout); + tokio::pin!(idle); + + loop { + tokio::select! { + incoming = socket.recv() => { + let Some(incoming) = incoming else { + break; + }; + let msg = match incoming { + Ok(msg) => msg, + Err(error) => { + tracing::debug!(target = "engram::realtime", %error, "websocket read failed"); + break; + } + }; + idle.as_mut().reset(tokio::time::Instant::now() + config.read_idle_timeout); + match msg { + Message::Text(text) => { + if text.len() > config.max_message_bytes { + metrics.oversized_messages.fetch_add(1, Ordering::Relaxed); + close_socket(&mut socket, close_code::SIZE, CLOSE_REASON_MESSAGE_TOO_LARGE).await; + break; + } + if let Ok(new_filter) = serde_json::from_str::(&text) { + manager.update_client_filter(&connection_id, new_filter); + tracing::debug!("Updated filter for client {}", connection_id); + } + } + Message::Binary(payload) => { + if payload.len() > config.max_message_bytes { + metrics.oversized_messages.fetch_add(1, Ordering::Relaxed); + close_socket(&mut socket, close_code::SIZE, CLOSE_REASON_MESSAGE_TOO_LARGE).await; + break; + } + } + Message::Close(_) => break, + Message::Ping(payload) => { + if !send_socket(&mut socket, Message::Pong(payload)).await { + break; + } + } + Message::Pong(_) => {} + } + } + event = rx.recv() => { + let Ok(event) = event else { + break; + }; + if manager.client_matches_event(&connection_id, &event) { + let Ok(json) = serde_json::to_string(&event) else { + continue; + }; + if !send_socket(&mut socket, Message::Text(json)).await { + break; + } + } + } + () = &mut idle => { + metrics.idle_disconnects.fetch_add(1, Ordering::Relaxed); + close_socket(&mut socket, close_code::POLICY, CLOSE_REASON_READ_IDLE).await; + break; + } + } + } + + tracing::info!("Client disconnected: {}", connection_id); +} + +async fn close_socket(socket: &mut WebSocket, code: u16, reason: &'static str) { + let close = socket.send(Message::Close(Some(CloseFrame { + code, + reason: reason.into(), + }))); + let _ = tokio::time::timeout(WS_CLOSE_TIMEOUT, close).await; +} + +async fn send_socket(socket: &mut WebSocket, message: Message) -> bool { + matches!( + tokio::time::timeout(WS_WRITE_TIMEOUT, socket.send(message)).await, + Ok(Ok(())) + ) +} diff --git a/tests/fixtures/transport_security.json b/tests/fixtures/transport_security.json index 686e1df6..642d73ef 100644 --- a/tests/fixtures/transport_security.json +++ b/tests/fixtures/transport_security.json @@ -12,6 +12,10 @@ "TSC-WS-DEFAULT-OFF", "TSC-WS-PUBLIC-NO-PROXY-BLOCKED", "TSC-WS-PUBLIC-TRUSTED-PROXY-ALLOWED", + "TSC-WS-DEFAULT-WORKSPACE-ALLOWED", + "TSC-WS-CROSS-WORKSPACE-FORBIDDEN", + "TSC-WS-ORIGIN-ALLOWED", + "TSC-WS-ORIGIN-FORBIDDEN", "TSC-GRPC-PUBLIC-NO-KEY-REFUSED", "TSC-GRPC-PUBLIC-KEY-MISSING-UNAUTHENTICATED", "TSC-GRPC-PUBLIC-KEY-INVALID-UNAUTHENTICATED", @@ -253,6 +257,90 @@ "rollback": "set ENGRAM_WS_PORT=0 or remove the public proxy route", "references": ["docs/security/THREAT_MODEL.md#TM-WS-UPGRADES-EVENTS", "src/realtime/server.rs"] }, + { + "id": "TSC-WS-DEFAULT-WORKSPACE-ALLOWED", + "transport": "websocket", + "surface": "/ws loopback subscription with omitted workspace", + "threat_model_row": "TM-WS-UPGRADES-EVENTS", + "bind": { "mode": "loopback", "address": "127.0.0.1", "public": false }, + "auth": { + "server_key": "absent", + "client_credential": "missing", + "scope": "default_workspace_read", + "proxy_trust": "not_applicable" + }, + "expected": { "startup": "allowed", "request": "success", "status": "101 Switching Protocols" }, + "failure": { + "kind": "none", + "signal": "subscription is registered only for the default workspace", + "must_not": "must not serialize events from any other workspace" + }, + "rollback": "set ENGRAM_WS_PORT=0", + "references": ["docs/security/THREAT_MODEL.md#TM-WS-UPGRADES-EVENTS", "src/realtime/server.rs"] + }, + { + "id": "TSC-WS-CROSS-WORKSPACE-FORBIDDEN", + "transport": "websocket", + "surface": "/ws?workspace=private from anonymous loopback principal", + "threat_model_row": "TM-WS-UPGRADES-EVENTS", + "bind": { "mode": "loopback", "address": "127.0.0.1", "public": false }, + "auth": { + "server_key": "absent", + "client_credential": "missing", + "scope": "unauthorized_workspace", + "proxy_trust": "not_applicable" + }, + "expected": { "startup": "allowed", "request": "forbidden", "status": "403 Forbidden" }, + "failure": { + "kind": "workspace_scope_denied", + "signal": "upgrade is rejected before client registration", + "must_not": "must not create an unfiltered subscription or serialize a private event" + }, + "rollback": "set ENGRAM_WS_PORT=0; never remove workspace filtering", + "references": ["docs/security/THREAT_MODEL.md#TM-WS-UPGRADES-EVENTS", "src/realtime/server.rs"] + }, + { + "id": "TSC-WS-ORIGIN-ALLOWED", + "transport": "websocket", + "surface": "/ws browser handshake with exact allowed Origin", + "threat_model_row": "TM-WS-UPGRADES-EVENTS", + "bind": { "mode": "loopback", "address": "127.0.0.1", "public": false }, + "auth": { + "server_key": "absent", + "client_credential": "missing", + "scope": "default_workspace_read", + "proxy_trust": "not_applicable" + }, + "expected": { "startup": "allowed", "request": "success", "status": "101 Switching Protocols" }, + "failure": { + "kind": "none", + "signal": "Origin exactly matches ENGRAM_WS_ALLOWED_ORIGINS", + "must_not": "must not treat suffix, prefix, or wildcard matches as equivalent" + }, + "rollback": "remove the browser origin from the allowlist or set ENGRAM_WS_PORT=0", + "references": ["docs/security/THREAT_MODEL.md#TM-WS-UPGRADES-EVENTS", "src/realtime/server.rs"] + }, + { + "id": "TSC-WS-ORIGIN-FORBIDDEN", + "transport": "websocket", + "surface": "/ws browser handshake with disallowed Origin", + "threat_model_row": "TM-WS-UPGRADES-EVENTS", + "bind": { "mode": "loopback", "address": "127.0.0.1", "public": false }, + "auth": { + "server_key": "absent", + "client_credential": "missing", + "scope": "default_workspace_read", + "proxy_trust": "not_applicable" + }, + "expected": { "startup": "allowed", "request": "forbidden", "status": "403 Forbidden" }, + "failure": { + "kind": "origin_denied", + "signal": "upgrade is rejected before client registration", + "must_not": "must not subscribe or serialize an event for a disallowed browser origin" + }, + "rollback": "set ENGRAM_WS_PORT=0; never replace the explicit allowlist with wildcard", + "references": ["docs/security/THREAT_MODEL.md#TM-WS-UPGRADES-EVENTS", "src/realtime/server.rs"] + }, { "id": "TSC-GRPC-PUBLIC-NO-KEY-REFUSED", "transport": "grpc", diff --git a/tests/http_transport_security.rs b/tests/http_transport_security.rs index 5b8eb06f..200c1d19 100644 --- a/tests/http_transport_security.rs +++ b/tests/http_transport_security.rs @@ -191,6 +191,139 @@ fn public_http_with_key_authenticates_mcp_and_sse() { ); } +#[test] +fn http_body_limit_accepts_normal_body_and_rejects_limit_plus_one_before_parse() { + let _guard = HTTP_SECURITY_TEST_LOCK.lock().expect("test lock"); + let port = pick_loopback_port(); + let mut process = ServerProcess::spawn_with_env( + &[ + "--transport", + "http", + "--http-port", + &port.to_string(), + "--http-api-key", + "secret-key", + ], + &[("ENGRAM_HTTP_MAX_BODY_BYTES", "256")], + ) + .expect("spawn body-limited server"); + process + .wait_for_log(&format!("HTTP transport listening on 127.0.0.1:{port}")) + .expect("readiness"); + + let normal = http_post_json(port, "/mcp", initialize_request(), Some("secret-key")) + .expect("normal bounded request"); + assert_eq!(normal.status, 200, "response: {}", normal.raw); + + let notification = http_post_json(port, "/mcp", notification_request(), Some("secret-key")) + .expect("bounded notification"); + assert_eq!(notification.status, 202, "response: {}", notification.raw); + + let oversized = + http_post_raw(port, "/mcp", &[b'x'; 257], Some("secret-key")).expect("oversized request"); + assert_eq!(oversized.status, 413, "response: {}", oversized.raw); +} + +#[test] +fn http_auth_rejects_oversized_body_before_collection_or_json_parse() { + let _guard = HTTP_SECURITY_TEST_LOCK.lock().expect("test lock"); + let port = pick_loopback_port(); + let mut process = ServerProcess::spawn_with_env( + &[ + "--transport", + "http", + "--http-port", + &port.to_string(), + "--http-api-key", + "secret-key", + ], + &[("ENGRAM_HTTP_MAX_BODY_BYTES", "64")], + ) + .expect("spawn authenticated body-limited server"); + process + .wait_for_log(&format!("HTTP transport listening on 127.0.0.1:{port}")) + .expect("readiness"); + + let response = http_post_raw(port, "/v1/mcp", &[b'x'; 65], None) + .expect("unauthenticated oversized request"); + assert_eq!(response.status, 401, "response: {}", response.raw); +} + +#[test] +fn http_request_timeout_bounds_slow_body_setup_with_stable_response() { + let _guard = HTTP_SECURITY_TEST_LOCK.lock().expect("test lock"); + let port = pick_loopback_port(); + let mut process = ServerProcess::spawn_with_env( + &[ + "--transport", + "http", + "--http-port", + &port.to_string(), + "--http-api-key", + "secret-key", + ], + &[("ENGRAM_HTTP_REQUEST_TIMEOUT_MS", "100")], + ) + .expect("spawn request-timeout server"); + process + .wait_for_log(&format!("HTTP transport listening on 127.0.0.1:{port}")) + .expect("readiness"); + + let response = http_post_slow_partial_body(port, "/mcp", 64, b"{", "secret-key") + .expect("slow body should receive timeout response"); + assert_eq!(response.status, 408, "response: {}", response.raw); + assert!( + response.body.contains("Request Timeout"), + "stable timeout body missing: {}", + response.raw + ); + + // Cancellation of the timed-out extraction must leave the listener healthy. + let healthy = http_post_json(port, "/mcp", initialize_request(), Some("secret-key")) + .expect("healthy request after timeout"); + assert_eq!(healthy.status, 200, "response: {}", healthy.raw); +} + +#[test] +fn http_timeout_bounds_sse_setup_but_not_established_stream() { + let _guard = HTTP_SECURITY_TEST_LOCK.lock().expect("test lock"); + let port = pick_loopback_port(); + let mut process = ServerProcess::spawn_with_env( + &[ + "--transport", + "http", + "--http-port", + &port.to_string(), + "--http-api-key", + "secret-key", + ], + &[("ENGRAM_HTTP_REQUEST_TIMEOUT_MS", "100")], + ) + .expect("spawn SSE timeout server"); + process + .wait_for_log(&format!("HTTP transport listening on 127.0.0.1:{port}")) + .expect("readiness"); + + let mut stream = open_sse_stream(port, "secret-key").expect("authorized SSE setup"); + thread::sleep(Duration::from_millis(250)); + stream + .set_nonblocking(true) + .expect("set SSE stream nonblocking"); + let mut chunk = [0_u8; 256]; + loop { + match stream.read(&mut chunk) { + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Ok(0) => panic!("established SSE stream was closed by setup timeout"), + Ok(_) => continue, + Err(error) => panic!("unexpected SSE read error: {error}"), + } + } + + let healthy = http_post_json(port, "/mcp", initialize_request(), Some("secret-key")) + .expect("healthy MCP request while SSE remains open"); + assert_eq!(healthy.status, 200, "response: {}", healthy.raw); +} + #[test] fn trusted_proxy_spoofed_forwarding_is_ignored_by_rate_key() { let _guard = HTTP_SECURITY_TEST_LOCK.lock().expect("test lock"); @@ -479,6 +612,46 @@ fn http_post_json_with_headers( http_request(port, &request) } +fn http_post_raw( + port: u16, + path: &str, + body: &[u8], + bearer: Option<&str>, +) -> Result { + let auth = bearer + .map(|token| format!("Authorization: Bearer {token}\r\n")) + .unwrap_or_default(); + let headers = format!( + "POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{auth}Connection: close\r\n\r\n", + body.len() + ); + let mut request = headers.into_bytes(); + request.extend_from_slice(body); + http_request_bytes(port, &request) +} + +fn http_post_slow_partial_body( + port: u16, + path: &str, + content_length: usize, + partial_body: &[u8], + bearer: &str, +) -> Result { + let mut stream = TcpStream::connect(("127.0.0.1", port)).map_err(|err| err.to_string())?; + stream + .set_read_timeout(Some(IO_TIMEOUT)) + .map_err(|err| err.to_string())?; + let headers = format!( + "POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nContent-Length: {content_length}\r\nAuthorization: Bearer {bearer}\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(headers.as_bytes()) + .and_then(|_| stream.write_all(partial_body)) + .and_then(|_| stream.flush()) + .map_err(|err| err.to_string())?; + read_http_response(&mut stream) +} + fn http_get(port: u16, path: &str, bearer: Option<&str>) -> Result { let auth = bearer .map(|token| format!("Authorization: Bearer {token}\r\n")) @@ -488,16 +661,55 @@ fn http_get(port: u16, path: &str, bearer: Option<&str>) -> Result Result { +fn open_sse_stream(port: u16, bearer: &str) -> Result { let mut stream = TcpStream::connect(("127.0.0.1", port)).map_err(|err| err.to_string())?; stream .set_read_timeout(Some(IO_TIMEOUT)) .map_err(|err| err.to_string())?; + let request = format!( + "GET /v1/events HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {bearer}\r\nConnection: keep-alive\r\n\r\n" + ); stream .write_all(request.as_bytes()) + .and_then(|_| stream.flush()) .map_err(|err| err.to_string())?; + + let mut raw = Vec::new(); + let mut byte = [0_u8; 1]; + while !raw.ends_with(b"\r\n\r\n") { + stream + .read_exact(&mut byte) + .map_err(|err| err.to_string())?; + raw.push(byte[0]); + if raw.len() > 16 * 1024 { + return Err("SSE response headers exceeded 16 KiB".to_string()); + } + } + let headers = String::from_utf8_lossy(&raw); + if !headers.starts_with("HTTP/1.1 200") + || !headers.to_ascii_lowercase().contains("text/event-stream") + { + return Err(format!("unexpected SSE response: {headers}")); + } + Ok(stream) +} + +fn http_request(port: u16, request: &str) -> Result { + http_request_bytes(port, request.as_bytes()) +} + +fn http_request_bytes(port: u16, request: &[u8]) -> Result { + let mut stream = TcpStream::connect(("127.0.0.1", port)).map_err(|err| err.to_string())?; + stream + .set_read_timeout(Some(IO_TIMEOUT)) + .map_err(|err| err.to_string())?; + stream.write_all(request).map_err(|err| err.to_string())?; stream.flush().map_err(|err| err.to_string())?; + read_http_response(&mut stream) +} + +fn read_http_response(stream: &mut TcpStream) -> Result { let mut raw = String::new(); let mut buf = [0_u8; 1024]; loop { @@ -550,6 +762,14 @@ fn initialize_request() -> serde_json::Value { }) } +fn notification_request() -> serde_json::Value { + json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }) +} + fn tool_call_request(arguments: serde_json::Value) -> serde_json::Value { json!({ "jsonrpc": "2.0", diff --git a/tests/v070_integration_tests.rs b/tests/v070_integration_tests.rs index 26351562..2993148e 100644 --- a/tests/v070_integration_tests.rs +++ b/tests/v070_integration_tests.rs @@ -315,7 +315,7 @@ fn test_sse_event_type_serialization() { // Verify RealtimeEvent can serialize to JSON (used by SSE handler) use engram::realtime::RealtimeEvent; - let event = RealtimeEvent::memory_created(42, "test content".to_string()); + let event = RealtimeEvent::memory_created(42, "test content".to_string(), "default"); let serialized = serde_json::to_string(&event).expect("event should serialize"); assert!(serialized.contains("memory_created")); assert!(serialized.contains("42"));