diff --git a/.cargo/config.toml b/.cargo/config.toml index a0c3348574..d42c757d31 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,3 +3,9 @@ # builds and much faster macOS link. Need lldb locals? Override per-shell: # CARGO_PROFILE_DEV_DEBUG=2 debug = "line-tables-only" + +[env] +# CMake 4 (pinned by hermit) removed compat with projects declaring +# cmake_minimum_required < 3.5; audiopus_sys's vendored opus declares 3.1. +# Same workaround CI uses. A value already set in the environment wins. +CMAKE_POLICY_VERSION_MINIMUM = "3.5" diff --git a/.env.example b/.env.example index 696d3a0617..e9ff8c082b 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,11 @@ RELAY_URL=ws://localhost:3000 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2 # BUZZ_MEDIA_UPLOADS_PER_MINUTE=30 +# Require Blossom t=get auth and relay membership for GET/HEAD /media/*. +# Keep off until desktop/mobile/CLI clients that attach media read auth are deployed. +# BUZZ_REQUIRE_MEDIA_GET_AUTH=false +# Legacy alias accepted by the relay while rollout docs catch up: +# BUZZ_REQUIRE_MEDIA_READ_AUTH=false # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b0b727e037..e9504afc70 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1 @@ -# TODO: verify @block/buzz-oss-team exists in GitHub org * @block/buzz-oss-team diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 0a86d289d6..e844e5e650 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -1,11 +1,15 @@ name: Auto-tag on Release PR Merge -# Four release lanes share this one workflow — adding a lane is one more branch -# prefix, never a forked copy: +# Five release lanes share this one workflow. Four use an explicit branch +# prefix; the main chart lane also auto-detects a Chart.yaml version bump so +# a chart feature PR can publish its own new version when merged: # # version-bump/ → tag v → dispatch release.yml (desktop app) # relay-release/ → tag relay-v → dispatch docker.yml (relay image) -# chart-release/ → tag chart-v → dispatch helm-chart.yml (helm chart) +# chart-release/ → tag chart-v → dispatch helm-chart.yml (main helm chart) +# push-chart-release/ → tag push-chart-v → dispatch push-gateway-helm-chart.yml +# any internal PR that bumps deploy/charts/buzz/Chart.yaml `version` +# → tag chart-v → dispatch helm-chart.yml (helm chart) # mobile-release/ → tag mobile-v → (manual sprout_ref for buzz-releases build — see below) # # The desktop, relay, and chart lanes dispatch their build workflow rather than @@ -36,10 +40,6 @@ jobs: auto-tag: if: > github.event.pull_request.merged == true && - (startsWith(github.event.pull_request.head.ref, 'version-bump/') || - startsWith(github.event.pull_request.head.ref, 'relay-release/') || - startsWith(github.event.pull_request.head.ref, 'chart-release/') || - startsWith(github.event.pull_request.head.ref, 'mobile-release/')) && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: @@ -48,12 +48,14 @@ jobs: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 - - name: Resolve lane and version from branch name + - name: Resolve release lane and version + id: release env: BRANCH: ${{ github.event.pull_request.head.ref }} run: | - # Lane is decided by branch prefix: the tag prefix and whether a - # downstream workflow_dispatch is needed both follow from it. + # Explicit release branches retain their established behavior. For an + # ordinary internal PR, publish only when Chart.yaml itself changed + # and its version differs from the PR's base commit. case "$BRANCH" in version-bump/*) VERSION="${BRANCH#version-bump/}" @@ -67,26 +69,42 @@ jobs: VERSION="${BRANCH#chart-release/}" TAG_PREFIX="chart-v" DISPATCH="helm-chart" ;; + push-chart-release/*) + VERSION="${BRANCH#push-chart-release/}" + TAG_PREFIX="push-chart-v" + DISPATCH="push-gateway-helm-chart" ;; mobile-release/*) VERSION="${BRANCH#mobile-release/}" TAG_PREFIX="mobile-v" DISPATCH="" ;; *) - echo "::error::Unhandled branch prefix: '$BRANCH'" - exit 1 ;; + parent_sha="$(git rev-parse HEAD^)" + old_version="$(git show "${parent_sha}:deploy/charts/buzz/Chart.yaml" 2>/dev/null | awk '/^version:/ {print $2}')" + VERSION="$(awk '/^version:/ {print $2}' deploy/charts/buzz/Chart.yaml)" + if [ -z "$old_version" ] || [ -z "$VERSION" ] || [ "$old_version" = "$VERSION" ]; then + echo "No release branch or chart version bump — nothing to tag" + echo "enabled=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + TAG_PREFIX="chart-v" + DISPATCH="helm-chart" ;; esac if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then - echo "::error::Invalid version in branch name: '$VERSION'" + echo "::error::Invalid release version: '$VERSION'" exit 1 fi - echo "version=$VERSION" >> "$GITHUB_ENV" - echo "tag=${TAG_PREFIX}${VERSION}" >> "$GITHUB_ENV" - echo "dispatch=$DISPATCH" >> "$GITHUB_ENV" + { + echo "enabled=true" + echo "version=$VERSION" + echo "tag=${TAG_PREFIX}${VERSION}" + echo "dispatch=$DISPATCH" + } >> "$GITHUB_OUTPUT" echo "Tagging ${TAG_PREFIX}${VERSION}" - name: Create and push tag + if: steps.release.outputs.enabled == 'true' env: - TAG: ${{ env.tag }} + TAG: ${{ steps.release.outputs.tag }} run: | EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')" if [ -n "$EXISTING_SHA" ]; then @@ -104,16 +122,16 @@ jobs: git push origin "$TAG" - name: Trigger release build - # The desktop and relay lanes dispatch their build workflow because the - # consumer's on:push:tags trigger is dead for auto-pushed tags (default - # GITHUB_TOKEN, recursion guard — see header comment). Mobile has no + # The desktop, relay, and chart lanes dispatch their build workflow + # because the consumer's on:push:tags trigger is dead for tags pushed + # by GITHUB_TOKEN (recursion guard — see header comment). Mobile has no # consumer yet, so dispatch="" skips this step entirely. - if: ${{ env.dispatch != '' }} + if: steps.release.outputs.enabled == 'true' && steps.release.outputs.dispatch != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DISPATCH: ${{ env.dispatch }} - VERSION: ${{ env.version }} - TAG: ${{ env.tag }} + DISPATCH: ${{ steps.release.outputs.dispatch }} + VERSION: ${{ steps.release.outputs.version }} + TAG: ${{ steps.release.outputs.tag }} run: | # Both workflows take the bare version (for the build) and the tag ref # (so the dispatch builds the tagged commit, not main). @@ -121,6 +139,7 @@ jobs: release) WORKFLOW="release.yml" ;; docker) WORKFLOW="docker.yml" ;; helm-chart) WORKFLOW="helm-chart.yml" ;; + push-gateway-helm-chart) WORKFLOW="push-gateway-helm-chart.yml" ;; *) echo "::error::Unhandled dispatch target: '$DISPATCH'" exit 1 ;; diff --git a/.github/workflows/benchmark-harbor.yml b/.github/workflows/benchmark-harbor.yml new file mode 100644 index 0000000000..31efe933c5 --- /dev/null +++ b/.github/workflows/benchmark-harbor.yml @@ -0,0 +1,39 @@ +name: Harbor Buzz Orchestra + +on: + push: + branches: [main] + paths: + - "benchmarks/harbor-buzz-orchestra/**" + - ".github/workflows/benchmark-harbor.yml" + pull_request: + paths: + - "benchmarks/harbor-buzz-orchestra/**" + - ".github/workflows/benchmark-harbor.yml" + +permissions: + contents: read + +jobs: + test: + name: Python tests and lint + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + - name: Test adapter + working-directory: benchmarks/harbor-buzz-orchestra + run: | + python -m pip install --disable-pip-version-check -e ".[dev]" + pytest -q + ruff check . + - name: Test provisioner + working-directory: benchmarks/harbor-buzz-orchestra/testbed + run: | + python -m pip install --disable-pip-version-check -e ".[dev]" + pytest -q + ruff check . diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc3ae81b03..88b52db57b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,7 @@ jobs: - 'pnpm-lock.yaml' mobile: - 'mobile/**' + - '.github/workflows/ci.yml' rust-lint: name: Rust Lint @@ -156,6 +157,10 @@ jobs: run: just desktop-test - name: Desktop build run: just desktop-build + - name: Desktop Tauri clippy + run: just desktop-tauri-clippy + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Desktop Tauri check run: just desktop-tauri-check env: @@ -230,15 +235,21 @@ jobs: run: just desktop-build - name: Desktop smoke e2e run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4 + - name: Summarize flaky tests + if: ${{ !cancelled() }} + run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})" + working-directory: desktop - name: Upload desktop smoke e2e artifacts - if: failure() + if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-smoke-e2e-artifacts-${{ matrix.shard }} path: | desktop/playwright-report + desktop/playwright-report.json desktop/test-results if-no-files-found: ignore + retention-days: 7 desktop: name: Desktop @@ -439,16 +450,22 @@ jobs: run: bash scripts/setup-desktop-test-data.sh - name: Desktop relay-backed e2e run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2 + - name: Summarize flaky tests + if: ${{ !cancelled() }} + run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)" + working-directory: desktop - name: Upload desktop integration artifacts - if: failure() + if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-e2e-integration-artifacts-${{ matrix.shard }} path: | desktop/playwright-report + desktop/playwright-report.json desktop/test-results /tmp/buzz-relay.log if-no-files-found: ignore + retention-days: 7 - name: Save pnpm store cache if: github.event_name == 'push' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 @@ -718,6 +735,8 @@ jobs: run: cd mobile && flutter analyze - name: Test run: cd mobile && flutter test + - name: Build Android debug APK + run: just mobile-build-android security: name: Security @@ -911,7 +930,7 @@ jobs: id: mesh_rev run: | set -euo pipefail - REV=$(grep -oE 'mesh-llm\.git\?rev=[0-9a-f]{40}' Cargo.lock | head -1 | grep -oE '[0-9a-f]{40}') + REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } echo "rev=$REV" >> "$GITHUB_OUTPUT" echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 847f123f5b..3b64d86c71 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -47,6 +47,7 @@ on: pull_request: paths: - "Dockerfile" + - "Dockerfile.push-gateway" - ".dockerignore" - ".github/workflows/docker.yml" - "Cargo.toml" @@ -304,3 +305,150 @@ jobs: echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block" echo '```' } >> "$GITHUB_STEP_SUMMARY" + + push-gateway-build: + name: Build public push gateway (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + persist-credentials: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + with: + buildkitd-config-inline: | + [worker.oci] + max-parallelism = 2 + - name: Log in to GHCR + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ghcr.io/block/buzz-push-gateway + labels: | + org.opencontainers.image.title=Buzz Push Gateway + org.opencontainers.image.description=Capability-gated APNs last hop for Buzz + org.opencontainers.image.licenses=Apache-2.0 + - name: Build and push by digest + id: build + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile.push-gateway + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }} + cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} + - name: Export digest + if: github.event_name != 'pull_request' + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: mkdir -p /tmp/gateway-digests && touch "/tmp/gateway-digests/${DIGEST#sha256:}" + - name: Upload digest + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gateway-digests-${{ matrix.arch }} + path: /tmp/gateway-digests/* + if-no-files-found: error + retention-days: 1 + + push-gateway-merge: + name: Publish public push gateway image + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + needs: push-gateway-build + timeout-minutes: 15 + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - name: Download per-arch digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/gateway-digests + pattern: gateway-digests-* + merge-multiple: true + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ghcr.io/block/buzz-push-gateway + tags: | + type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} + - name: Merge and publish manifest + id: manifest + working-directory: /tmp/gateway-digests + env: + META_TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + tags=(); while IFS= read -r tag; do [ -n "$tag" ] && tags+=("-t" "$tag"); done <<< "$META_TAGS" + digests=(); for digest in *; do digests+=("ghcr.io/block/buzz-push-gateway@sha256:${digest}"); done + docker buildx imagetools create "${tags[@]}" "${digests[@]}" + first_tag=$(echo "$META_TAGS" | head -n1) + digest=$(docker buildx imagetools inspect "$first_tag" --format '{{json .Manifest}}' | jq -r '.digest') + echo "digest=${digest}" >> "$GITHUB_OUTPUT" + - name: Attest gateway image provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ghcr.io/block/buzz-push-gateway + subject-digest: ${{ steps.manifest.outputs.digest }} + push-to-registry: true + - name: Gateway publication summary + env: + GATEWAY_DIGEST: ${{ steps.manifest.outputs.digest }} + GATEWAY_TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + { + echo "### Published \`ghcr.io/block/buzz-push-gateway\`" + echo + printf "**Digest:** \`%s\`\n" "$GATEWAY_DIGEST" + echo + echo '**Tags:**' + echo "\`\`\`" + printf '%s\n' "$GATEWAY_TAGS" + echo "\`\`\`" + echo + echo 'Verify provenance before deployment:' + echo "\`\`\`" + printf 'gh attestation verify oci://ghcr.io/block/buzz-push-gateway@%s --owner block\n' "$GATEWAY_DIGEST" + echo "\`\`\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index 7b61fe3fbf..0fa156f6c7 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -40,6 +40,7 @@ on: pull_request: paths: - "deploy/charts/buzz/**" + - "deploy/charts/buzz-push-gateway/**" - ".github/workflows/helm-chart.yml" - "ct.yaml" @@ -88,6 +89,9 @@ jobs: - name: Build chart dependencies run: helm dependency build deploy/charts/buzz + - name: Push gateway chart lint + render guard + run: deploy/charts/buzz-push-gateway/tests/render.sh + - name: ct lint run: ct lint --config ct.yaml --all diff --git a/.github/workflows/push-gateway-helm-chart.yml b/.github/workflows/push-gateway-helm-chart.yml new file mode 100644 index 0000000000..1aaa910021 --- /dev/null +++ b/.github/workflows/push-gateway-helm-chart.yml @@ -0,0 +1,68 @@ +name: push gateway helm chart + +on: + workflow_dispatch: + inputs: + version: + description: "Chart semver (without push-chart-v prefix)" + required: true + ref: + description: "Matching push-chart-v tag" + required: true + push: + tags: ["push-chart-v[0-9]*"] + pull_request: + paths: + - "deploy/charts/buzz-push-gateway/**" + - ".github/workflows/push-gateway-helm-chart.yml" +permissions: {} +env: + CHART_REPO: oci://ghcr.io/block/buzz/charts +jobs: + validate: + runs-on: ubuntu-latest + permissions: { contents: read } + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + fetch-depth: 0 + - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 + with: { version: v3.16.4 } + - run: deploy/charts/buzz-push-gateway/tests/render.sh + - run: deploy/charts/buzz-push-gateway/tests/release-contract.sh + publish: + if: github.event_name != 'pull_request' + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + fetch-depth: 0 + persist-credentials: false + - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 + with: { version: v3.16.4 } + - name: Verify release tag and chart version + env: + INPUT_VERSION: ${{ inputs.version }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] + test "$(helm show chart deploy/charts/buzz-push-gateway | awk '/^version:/ {print $2}')" = "$version" + if [ -n "$INPUT_VERSION" ]; then + test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/push-chart-v${version}^{commit}")" + fi + echo "VERSION=$version" >> "$GITHUB_ENV" + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - run: helm package deploy/charts/buzz-push-gateway --destination dist + - run: helm push "dist/buzz-push-gateway-${VERSION}.tgz" "$CHART_REPO" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5bafa3c6f..009d0fb7dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,7 +138,7 @@ jobs: id: mesh_rev run: | set -euo pipefail - REV=$(grep -oE 'mesh-llm\.git\?rev=[0-9a-f]{40}' Cargo.lock | head -1 | grep -oE '[0-9a-f]{40}') + REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } echo "rev=$REV" >> "$GITHUB_OUTPUT" echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" @@ -199,6 +199,15 @@ jobs: fi echo "dmg=$DMG" >> "$GITHUB_OUTPUT" + - name: Set DMG Finder label text size + env: + DMG_PATH: ${{ steps.unsigned.outputs.dmg }} + run: desktop/scripts/set-dmg-finder-text-size.sh "$DMG_PATH" 14 + + # mdx-ios-codesign-helper discovers this file by its exact lowercase basename. + - name: Stage signing entitlements + run: cp desktop/src-tauri/Entitlements.plist "${RUNNER_TEMP}/entitlements.plist" + - name: Codesign and Notarize id: codesign uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0 @@ -206,7 +215,7 @@ jobs: osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }} codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }} unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }} - entitlements-plist-path: desktop/src-tauri/Entitlements.plist + entitlements-plist-path: ${{ runner.temp }}/entitlements.plist artifact-name: buzz-${{ github.sha }}-${{ github.run_id }}-arm64 - name: Replace DMG and rebuild updater archive @@ -243,6 +252,8 @@ jobs: desktop/src-tauri/target/release/bundle/macos/Buzz.app spctl --assess --type execute --verbose=4 \ desktop/src-tauri/target/release/bundle/macos/Buzz.app + desktop/scripts/verify-macos-entitlements.sh \ + desktop/src-tauri/target/release/bundle/macos/Buzz.app - name: Locate build artifacts id: artifacts @@ -361,6 +372,15 @@ jobs: fi echo "dmg=$DMG" >> "$GITHUB_OUTPUT" + - name: Set DMG Finder label text size + env: + DMG_PATH: ${{ steps.unsigned.outputs.dmg }} + run: desktop/scripts/set-dmg-finder-text-size.sh "$DMG_PATH" 14 + + # mdx-ios-codesign-helper discovers this file by its exact lowercase basename. + - name: Stage signing entitlements + run: cp desktop/src-tauri/Entitlements.plist "${RUNNER_TEMP}/entitlements.plist" + - name: Codesign and Notarize id: codesign uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0 @@ -368,7 +388,7 @@ jobs: osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }} codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }} unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }} - entitlements-plist-path: desktop/src-tauri/Entitlements.plist + entitlements-plist-path: ${{ runner.temp }}/entitlements.plist artifact-name: buzz-${{ github.sha }}-${{ github.run_id }}-x64 - name: Replace DMG and rebuild updater archive @@ -403,6 +423,7 @@ jobs: APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos/Buzz.app" codesign --verify --deep --strict --verbose=2 "$APP_DIR" spctl --assess --type execute --verbose=4 "$APP_DIR" + desktop/scripts/verify-macos-entitlements.sh "$APP_DIR" - name: Locate updater archive id: artifacts @@ -447,42 +468,48 @@ jobs: name: Release Linux if: github.repository == 'block/buzz' runs-on: ubuntu-latest + # Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh. + container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 needs: setup timeout-minutes: 60 permissions: contents: write + env: + # AppImage tools (linuxdeploy, appimagetool) are themselves AppImages. + # Containers lack FUSE, so we must use the extract-and-run fallback. + APPIMAGE_EXTRACT_AND_RUN: "1" + # This job runs in a container where the default run shell is dash; + # the AppImage steps below use bash-only syntax ([[ ]], mapfile, arrays). + defaults: + run: + shell: bash outputs: archive_name: ${{ steps.linux-artifacts.outputs.archive_name }} sig: ${{ steps.read-sig.outputs.sig }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }} - persist-credentials: false - - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: desktop/src-tauri - lookup-only: true - - - name: Install Tauri dependencies (Linux) + - name: Install system dependencies env: DEBIAN_FRONTEND: noninteractive run: | - sudo apt-get update \ + # Must run first: bare ubuntu:22.04 ships without curl, wget, git, or + # ca-certificates. activate-hermit bootstraps via curl+HTTPS (needs + # both), and actions/checkout falls back to a REST tarball without git. + # Running as root — no sudo needed. + apt-get update \ -o Acquire::Retries=3 \ -o Acquire::http::Timeout=30 \ -o Acquire::https::Timeout=30 - sudo apt-get install -y --no-install-recommends \ + apt-get install -y --no-install-recommends \ -o Acquire::Retries=3 \ -o Acquire::http::Timeout=30 \ -o Acquire::https::Timeout=30 \ -o DPkg::Lock::Timeout=120 \ build-essential \ + ca-certificates \ curl \ + desktop-file-utils \ file \ + git \ libasound2-dev \ libayatana-appindicator3-dev \ libgtk-3-dev \ @@ -491,7 +518,73 @@ jobs: libwebkit2gtk-4.1-dev \ libxdo-dev \ patchelf \ - wget + pkg-config \ + squashfs-tools \ + wget \ + xdg-utils + # Install GitHub CLI — preinstalled on runners but absent in containers. + # wget and ca-certificates are now available from the step above. + mkdir -p -m 755 /etc/apt/keyrings + wget -q --tries=3 --timeout=30 -O /usr/share/keyrings/githubcli-archive-keyring.gpg \ + https://cli.github.com/packages/githubcli-archive-keyring.gpg + # Pin the keyring like appimagetool below. If GitHub rotates the + # keyring this fails loudly — recompute and update the hash. + echo "6084d5d7bd8e288441e0e94fc6275570895da18e6751f70f057485dc2d1a811b /usr/share/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c + chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list + apt-get update + apt-get install -y --no-install-recommends gh + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }} + persist-credentials: false + + - name: Mark workspace safe for git (containerized job) + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: desktop/src-tauri + lookup-only: true + + - name: Install appimagetool + run: | + # Pin to an immutable release tag to avoid supply-chain drift from the + # mutable `continuous` tag. Tag: 1.9.1, asset: appimagetool-.AppImage + # (https://github.com/AppImage/appimagetool/releases/tag/1.9.1) + case "$(uname -m)" in + x86_64) ARCH_SUFFIX="x86_64" ;; + aarch64) ARCH_SUFFIX="aarch64" ;; + *) + echo "::error::Unsupported architecture: $(uname -m)" + exit 1 + ;; + esac + wget -q --tries=3 --timeout=30 -O /tmp/appimagetool \ + "https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${ARCH_SUFFIX}.AppImage" + # SHA256 integrity check. Refuse to run an unverified binary: if a new + # arch (e.g. aarch64) is enabled in CI, compute its hash and add it here. + if [[ "$ARCH_SUFFIX" == "x86_64" ]]; then + echo "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool" | sha256sum -c + else + echo "::error::No pinned SHA256 for appimagetool-${ARCH_SUFFIX} — add it before enabling this architecture" + exit 1 + fi + install -m 755 /tmp/appimagetool /usr/local/bin/appimagetool + # appimagetool otherwise fetches the AppImage type2 runtime from the + # MUTABLE `continuous` tag at repack time — the runtime is the first + # code users execute, so pin it too. Tag: 20251108, hash is for the + # x86_64 asset (non-x86_64 already hard-fails above). + # (https://github.com/AppImage/type2-runtime/releases/tag/20251108) + wget -q --tries=3 --timeout=30 -O /tmp/appimage-runtime \ + "https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-${ARCH_SUFFIX}" + echo "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d /tmp/appimage-runtime" | sha256sum -c + install -D -m 644 /tmp/appimage-runtime /usr/local/lib/appimage-runtime + echo "APPIMAGETOOL_RUNTIME_FILE=/usr/local/lib/appimage-runtime" >> "$GITHUB_ENV" - name: Install desktop dependencies run: just desktop-install-ci @@ -524,6 +617,22 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + - name: Fix AppImage (remove infra libs, symlink system GStreamer) + run: | + mapfile -t APPIMAGES < <(find desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -type f) + if [[ ${#APPIMAGES[@]} -eq 0 ]]; then + echo "::error::No AppImage found to post-process" + exit 1 + fi + if [[ ${#APPIMAGES[@]} -gt 1 ]]; then + echo "::error::Expected exactly one AppImage, found ${#APPIMAGES[@]}: ${APPIMAGES[*]}" + exit 1 + fi + bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}" + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + - name: Locate Linux build artifacts id: linux-artifacts run: | diff --git a/AGENTS.md b/AGENTS.md index 089d4fe479..be1f1cc4a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,9 @@ commit. **Pre-push hooks** run clippy (workspace + Tauri) and fast unit tests in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter) — no overlap with pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix all formatting in one shot. Run `just ci` for the full local gate. Run `just hooks` to -re-install hooks after env changes. +re-install hooks after env changes. Before agents run Git or hooks, activate the +repo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook +commands to compensate for an unconfigured shell `PATH`. Additional rules: - No `unsafe` code diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c62b86e08..64e5986125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,252 @@ # Changelog +## v0.4.6 + +- Polish agent and team sharing and snapshots ([#1852](https://github.com/block/buzz/pull/1852)) ([`2f3fa586f`](https://github.com/block/buzz/commit/2f3fa586f3bd12b26b8a9dc69a424d5c28b1e27e)) +- fix(desktop): filter punctuation from avatar initials ([#1904](https://github.com/block/buzz/pull/1904)) ([`63e099709`](https://github.com/block/buzz/commit/63e099709667669be41426e7144a4a9057f8537b)) +- fix(desktop): preserve snapshot attachment URLs ([#1905](https://github.com/block/buzz/pull/1905)) ([`1ab7bbfe4`](https://github.com/block/buzz/commit/1ab7bbfe453d10cfd3522c358e5ed18931f1e1ec)) +- Chat-first agent creation via portable buzz CLI drafts ([#1878](https://github.com/block/buzz/pull/1878)) ([`54e174b5e`](https://github.com/block/buzz/commit/54e174b5e1424c13fe7451c86a418ba8b9c01f1c)) +- feat(desktop): enable community rail by default ([#1902](https://github.com/block/buzz/pull/1902)) ([`55a46b063`](https://github.com/block/buzz/commit/55a46b063e291862c034d4ce685b3d3a92804951)) +- feat(desktop): redesign Nostr bind verification flow ([#1850](https://github.com/block/buzz/pull/1850)) ([`abfe78aaf`](https://github.com/block/buzz/commit/abfe78aafb131c9100f33ed361850fb9cfeb0881)) +- fix(desktop): simplify sign out settings row ([#1903](https://github.com/block/buzz/pull/1903)) ([`3e8dae7ff`](https://github.com/block/buzz/commit/3e8dae7ff3c73b1b2b1ae26d5a4bed5db17cb269)) +- feat(desktop): Send feedback modal + profile presence chip ([#1756](https://github.com/block/buzz/pull/1756)) ([`f5c33d333`](https://github.com/block/buzz/commit/f5c33d3335fc3fa7e304bb51a9931d4cc741d255)) +- fix(desktop): seed timeline virtualization row heights ([#1887](https://github.com/block/buzz/pull/1887)) ([`406cf7911`](https://github.com/block/buzz/commit/406cf7911ee1f335fdf0be131ee057ea2c455037)) +- fix(desktop): dedupe react-dismissable-layer to stop modal menu → dialog freezes ([#1899](https://github.com/block/buzz/pull/1899)) ([`366567ac2`](https://github.com/block/buzz/commit/366567ac20b4786c1ca8e754ac5c45f0e7d59122)) +- fix(desktop): clean up Remind me later dialog footer and loading state ([#1898](https://github.com/block/buzz/pull/1898)) ([`40fe65095`](https://github.com/block/buzz/commit/40fe6509583e93054eab04906aacde834c5fcc68)) +- fix(desktop): let sidebar action card description wrap over multiple lines ([#1891](https://github.com/block/buzz/pull/1891)) ([`b36ee3e30`](https://github.com/block/buzz/commit/b36ee3e30203b4cc4ec8056ae4d9c57acf26891e)) +- mesh: upgrade runtime, enforce membership, add shared compute provider ([#1656](https://github.com/block/buzz/pull/1656)) ([`54638ff4b`](https://github.com/block/buzz/commit/54638ff4bb5af2d3d3759b44118b43052f814bb1)) + + +## v0.4.5 + +- test(desktop): remove stale collapsed home chrome check ([#1871](https://github.com/block/buzz/pull/1871)) ([`395a3f149`](https://github.com/block/buzz/commit/395a3f1492807b065f8edbee9bb9c8d51018095a)) +- fix(desktop): hide message action for inaccessible agents ([#1883](https://github.com/block/buzz/pull/1883)) ([`8ce9e7914`](https://github.com/block/buzz/commit/8ce9e791453a262f21e675d3f5d27ace22a09e9b)) +- feat(desktop): add API key field, global default indicators, and collapsed advanced ([#1875](https://github.com/block/buzz/pull/1875)) ([`1742dce7b`](https://github.com/block/buzz/commit/1742dce7b25676e91631ce4056bdd2bbc05944ab)) +- fix(desktop): foreground app for deep links ([#1880](https://github.com/block/buzz/pull/1880)) ([`a26168f55`](https://github.com/block/buzz/commit/a26168f553fd8bfce8b5df88915e76ff2d7004ee)) +- chore(desktop): replace setup ? operators with clean exit ([#1882](https://github.com/block/buzz/pull/1882)) ([`4c106d878`](https://github.com/block/buzz/commit/4c106d878564e3f5b4def92f4667fe95dec015f4)) +- fix(desktop): remediate all onboarding dead paths (D1-D7) ([#1856](https://github.com/block/buzz/pull/1856)) ([`5bb619295`](https://github.com/block/buzz/commit/5bb6192951bd57c535a34cd835aba20714f408b6)) + + +## v0.4.4 + +- fix(desktop): allow staged updater commands ([#1870](https://github.com/block/buzz/pull/1870)) ([`109c2c526`](https://github.com/block/buzz/commit/109c2c52641c5edd50890bffef9a236a10ea6106)) +- feat(teams): unify team model, snapshot sharing, and PNG memory parity ([#1846](https://github.com/block/buzz/pull/1846)) ([`448baeef7`](https://github.com/block/buzz/commit/448baeef770478411678e2f0be1729f04b6800d7)) +- fix(desktop): always start agents and hide unavailable mesh ([#1860](https://github.com/block/buzz/pull/1860)) ([`37bc962cb`](https://github.com/block/buzz/commit/37bc962cb8fb60857337bbfb97eb4d31c359de15)) +- Relay mesh: cross-pod tunnel + huddle transport (buzz-relay-mesh) ([#1670](https://github.com/block/buzz/pull/1670)) ([`ccb021d71`](https://github.com/block/buzz/commit/ccb021d71339009aabedc383c8f3d8e5c23e1e42)) +- feat(desktop): add Sign Out to Settings to reset and relaunch ([#1842](https://github.com/block/buzz/pull/1842)) ([`cf9723796`](https://github.com/block/buzz/commit/cf97237965a5ed9870f1c1f73ce9952e963b418b)) + + +## v0.4.3 + +- Stabilize persona and stream end-to-end tests ([#1865](https://github.com/block/buzz/pull/1865)) ([`916de4e4d`](https://github.com/block/buzz/commit/916de4e4dbbf9299695a91defa6e3802b1820256)) +- refactor(clients): standardize product naming on community ([#1858](https://github.com/block/buzz/pull/1858)) ([`3e76481a1`](https://github.com/block/buzz/commit/3e76481a149bb3de459298cc989505c470c2372c)) +- fix(desktop): retain live rows after window exhaustion ([#1810](https://github.com/block/buzz/pull/1810)) ([`fa6f4819b`](https://github.com/block/buzz/commit/fa6f4819b3468a5a3725356dd71463bb37cc30c7)) +- Add private product feedback sidecar ([#1857](https://github.com/block/buzz/pull/1857)) ([`af190c93e`](https://github.com/block/buzz/commit/af190c93e1048af64c3fbfb3831c689cb703997c)) +- fix(desktop): resolve Doctor install shell and command detection on Windows ([#1854](https://github.com/block/buzz/pull/1854)) ([`1aec7ea7a`](https://github.com/block/buzz/commit/1aec7ea7a798c8bdb131a802dcd3d2ea0fb8692a)) +- fix(desktop): navigate to channel from inbox thread header ([#1847](https://github.com/block/buzz/pull/1847)) ([`ff11f26bc`](https://github.com/block/buzz/commit/ff11f26bcdc8e107d7d04f5682b2d17f586536d6)) +- feat(desktop): surface needsRestart badge on live UI surfaces ([#1853](https://github.com/block/buzz/pull/1853)) ([`b1d323c4e`](https://github.com/block/buzz/commit/b1d323c4e7ddbabbe52a868f3699a523bb6c010e)) +- fix(desktop): prevent menu-to-dialog UI lockups ([#1839](https://github.com/block/buzz/pull/1839)) ([`7f81d93eb`](https://github.com/block/buzz/commit/7f81d93eb1cf6a10c84d948d91c45a1fd4778060)) +- feat(desktop): add conversation-style DM composer ([#1768](https://github.com/block/buzz/pull/1768)) ([`bb9d9b0c0`](https://github.com/block/buzz/commit/bb9d9b0c049b9561458414b24eff157c6bbbd446)) +- feat(push): add public APNs gateway ([#1770](https://github.com/block/buzz/pull/1770)) ([`1c006822e`](https://github.com/block/buzz/commit/1c006822e4484d68e33fce14f9139c2f70ce9d66)) +- fix(desktop): stabilize agent identity restore ([#1831](https://github.com/block/buzz/pull/1831)) ([`3370cd083`](https://github.com/block/buzz/commit/3370cd0836e458f519cf90fe6299279cde6c7463)) +- fix(observer): align scroll-anchor ids with transcript display-block keys ([#1849](https://github.com/block/buzz/pull/1849)) ([`1df4a4e77`](https://github.com/block/buzz/commit/1df4a4e77da732cadad91738bc0271a926c22040)) +- [codex] Add view activity label to agent popover ([#1748](https://github.com/block/buzz/pull/1748)) ([`458c7f915`](https://github.com/block/buzz/commit/458c7f9154fc9580a766117cad0e3e378ffe5ac0)) +- ci(desktop): surface flaky E2E tests instead of retry-masking them ([#1838](https://github.com/block/buzz/pull/1838)) ([`a65340630`](https://github.com/block/buzz/commit/a653406309aa6dab8122cf3e84ddeca0c87013be)) +- fix(desktop): treat channel creator as member before 39002 provisioning ([#1830](https://github.com/block/buzz/pull/1830)) ([`7e62a25af`](https://github.com/block/buzz/commit/7e62a25af0bc71df17de806673ed02514e556e6e)) + + +## v0.4.2 + +- fix(desktop): unify observer feed scroll onto useAnchoredScroll ([#1825](https://github.com/block/buzz/pull/1825)) ([`d75c2e913`](https://github.com/block/buzz/commit/d75c2e913eef763e8ea2e748c58353c3a22d544d)) +- fix(desktop): sync sidebar update card copy with global installing state ([#1827](https://github.com/block/buzz/pull/1827)) ([`b63a2e423`](https://github.com/block/buzz/commit/b63a2e4231d27f61eae952d2febab36489e773a5)) +- fix(desktop): parse codex ACP plan entries[] into checklist ([#1824](https://github.com/block/buzz/pull/1824)) ([`34dcd13d5`](https://github.com/block/buzz/commit/34dcd13d559b6c606312fca1bbc2c52310b35603)) +- fix(desktop): resolve Git Bash for Windows shell tool via PATH/git/registry fallback ([#1821](https://github.com/block/buzz/pull/1821)) ([`020ac7f40`](https://github.com/block/buzz/commit/020ac7f405c24f72e390b605571fb65e2481612c)) +- feat(desktop): show read-only MCP server config ([#1780](https://github.com/block/buzz/pull/1780)) ([`51ee1c473`](https://github.com/block/buzz/commit/51ee1c473dae018499e42106086c359c19108137)) +- fix(desktop): require user action before applying updates ([#1820](https://github.com/block/buzz/pull/1820)) ([`deed64a14`](https://github.com/block/buzz/commit/deed64a14f1ec39a1242bed46711e44d77a3086e)) +- fix(desktop): preserve selected inbox rows through reflow ([#1817](https://github.com/block/buzz/pull/1817)) ([`c06ddcf14`](https://github.com/block/buzz/commit/c06ddcf14b81834a59a4eedf020ccbe8ca72dfd7)) +- fix(snapshot): send Buzz shares as PNG avatar cards ([#1811](https://github.com/block/buzz/pull/1811)) ([`a6cfd6551`](https://github.com/block/buzz/commit/a6cfd65511ee06f9073f2d5405e2242a9e2ff4ef)) +- test(desktop): consolidate and stabilize scroll coverage ([#1815](https://github.com/block/buzz/pull/1815)) ([`d41b4c390`](https://github.com/block/buzz/commit/d41b4c390522d63a0a4e1b3e8a93fe9c84289b2a)) +- fix(buzz-agent): support max effort for gpt-5.6 family ([#1806](https://github.com/block/buzz/pull/1806)) ([`e34e0974a`](https://github.com/block/buzz/commit/e34e0974a149a2b776e779e5798cc4252cc5030c)) +- fix(desktop): widen probe test timing margins for parallel pre-push ([#1812](https://github.com/block/buzz/pull/1812)) ([`fac79215a`](https://github.com/block/buzz/commit/fac79215a4cdc8289cc94282641e69d822d419d6)) +- fix(desktop): keep pairing tests after production items ([#1807](https://github.com/block/buzz/pull/1807)) ([`68909629f`](https://github.com/block/buzz/commit/68909629f96a95797a627b19e76e03bcbd6983de)) +- chore(deps): update rust crate nostr to v0.44.4 ([#1789](https://github.com/block/buzz/pull/1789)) ([`f1f5002a9`](https://github.com/block/buzz/commit/f1f5002a970bb651173ef49027e9609b903e3fa3)) +- Add optional standalone pairing relay to Helm chart ([#1799](https://github.com/block/buzz/pull/1799)) ([`9b47c8548`](https://github.com/block/buzz/commit/9b47c8548fd061fbb806ea8b9ddee831c19cf80e)) +- chore(ci): lint desktop Tauri crate in CI ([#1801](https://github.com/block/buzz/pull/1801)) ([`bea507d8a`](https://github.com/block/buzz/commit/bea507d8aa611d8d0be893bac1339dad50162d7b)) +- fix(desktop): preserve live events and window order across channel refreshes ([#1802](https://github.com/block/buzz/pull/1802)) ([`e09e94ab8`](https://github.com/block/buzz/commit/e09e94ab88543ea0e0c6411092852c1c98187feb)) +- fix(desktop): restore macOS navigation chrome alignment ([#1797](https://github.com/block/buzz/pull/1797)) ([`109800fe5`](https://github.com/block/buzz/commit/109800fe5a67c560e3ffb750e7df28177c03f509)) +- fix(desktop): omit invalid snapshot imeta thumbnails ([#1800](https://github.com/block/buzz/pull/1800)) ([`b32f2c08f`](https://github.com/block/buzz/commit/b32f2c08f7cdbad1bcc3b6a73711c2e7a21c71bd)) +- fix(desktop): centralize known-agent trust set in useKnownAgentPubkeys ([#1703](https://github.com/block/buzz/pull/1703)) ([`095f3c728`](https://github.com/block/buzz/commit/095f3c7280153394bf2a8215b1355ef734af9ee6)) +- chore(deps): update all non-major dependencies ([#1778](https://github.com/block/buzz/pull/1778)) ([`221aceb1c`](https://github.com/block/buzz/commit/221aceb1c2f794db0bf2050d1cc66fe8d2db9cec)) +- fix(desktop): pass augmented PATH to codex-acp version probe ([#1794](https://github.com/block/buzz/pull/1794)) ([`10ae66893`](https://github.com/block/buzz/commit/10ae66893c8b454255298d0eab40bfd47a46041c)) +- feat(desktop): add team snapshot sharing commands ([#1790](https://github.com/block/buzz/pull/1790)) ([`150514f66`](https://github.com/block/buzz/commit/150514f665c5aeebb83f9c42c17e793d88ee6b9f)) +- feat(managed_agents): add buzz-team-snapshot v1 codec ([#1784](https://github.com/block/buzz/pull/1784)) ([`aceb5e045`](https://github.com/block/buzz/commit/aceb5e0456c19761f1210585be520fa291ef903c)) + + +## v0.4.1 + +- feat(desktop): auto-restart setup-mode agents after adapter install, badge drift fallback ([#1786](https://github.com/block/buzz/pull/1786)) ([`11a286b9a`](https://github.com/block/buzz/commit/11a286b9a4284f4f80943d1321f86fa41c472dcf)) +- Retain a stable message window in channel timelines ([#1698](https://github.com/block/buzz/pull/1698)) ([`b0ad2b66d`](https://github.com/block/buzz/commit/b0ad2b66ddb2d91bcee1473050d21f27e2a38eac)) +- refactor(desktop): remove legacy persona-card flows ([#1781](https://github.com/block/buzz/pull/1781)) ([`1fa91f569`](https://github.com/block/buzz/commit/1fa91f569114cfa1d22951311fe80cef0def36d7)) +- Serialize managed agent PATH tests ([#1777](https://github.com/block/buzz/pull/1777)) ([`1fc7488ea`](https://github.com/block/buzz/commit/1fc7488ea58accf8bd96afe94cd93ee54174df00)) +- fix(desktop): stable inbox selection and scroll/draft preservation ([#1760](https://github.com/block/buzz/pull/1760)) ([`7e3252ea3`](https://github.com/block/buzz/commit/7e3252ea3e6b5c6c0db2e2a36c839d9cc2700c89)) +- feat(desktop): add buzz-agent-snapshot v1 export, import, sender-native send, and recipient card/import ([#1753](https://github.com/block/buzz/pull/1753)) ([`1580046b3`](https://github.com/block/buzz/commit/1580046b3c2c3d208e84fb240467454b8f3813f0)) +- refactor(desktop): remove vestigial MCP toolsets config ([#1776](https://github.com/block/buzz/pull/1776)) ([`dfec75b3c`](https://github.com/block/buzz/commit/dfec75b3c0b8080529e4d9089d4ed80e3902aaed)) +- feat(desktop): add key backup and agent defaults to onboarding, make avatar optional ([#1767](https://github.com/block/buzz/pull/1767)) ([`d2e87e1ca`](https://github.com/block/buzz/commit/d2e87e1ca71368312576b7f492818a50042e61e1)) +- fix(desktop): correct provider and model handling in agent config dialogs ([#1764](https://github.com/block/buzz/pull/1764)) ([`f3319dd11`](https://github.com/block/buzz/commit/f3319dd11102bfd52e1ec3065a4d9272a03075ec)) +- fix(desktop): cascade persona deletes and restart agents on global config save ([#1766](https://github.com/block/buzz/pull/1766)) ([`7c346d7f8`](https://github.com/block/buzz/commit/7c346d7f85e7fcb64999b502306f7895d37fb42a)) +- fix(desktop): make doctor installs retryable with per-runtime progress and auth status ([#1765](https://github.com/block/buzz/pull/1765)) ([`25bb71475`](https://github.com/block/buzz/commit/25bb7147523f27a88f1bfece0310889442699c5f)) +- fix(codex): swap to @agentclientprotocol/codex-acp 1.x + detect outdated adapter ([#1750](https://github.com/block/buzz/pull/1750)) ([`6d1a77e49`](https://github.com/block/buzz/commit/6d1a77e49255f18434593c9c7daa811f5c76db0b)) +- fix(desktop): show thread replies loader ([#1773](https://github.com/block/buzz/pull/1773)) ([`e07e02c0e`](https://github.com/block/buzz/commit/e07e02c0eb76376b96e0a9d5fe27009111430845)) +- fix(desktop): restore multi-image mosaic galleries ([#1769](https://github.com/block/buzz/pull/1769)) ([`2c41e9e6b`](https://github.com/block/buzz/commit/2c41e9e6b836fad57da981ec9329fe51e5796085)) +- Group channel membership events ([#1713](https://github.com/block/buzz/pull/1713)) ([`5dc70bd0b`](https://github.com/block/buzz/commit/5dc70bd0b8561ce8aa24338903167e62d6035d10)) +- Fix desktop launch motion and reaction spacing ([#1717](https://github.com/block/buzz/pull/1717)) ([`89e2e2e4a`](https://github.com/block/buzz/commit/89e2e2e4adf4d062995aed46b32f4935c6992513)) +- fix(desktop): align sidebar search across themes ([#1712](https://github.com/block/buzz/pull/1712)) ([`53c177e34`](https://github.com/block/buzz/commit/53c177e341d4f9e7b254b7c83af39380e9b10242)) +- fix(desktop): Buzz theme flicker, white bar & accent-picker motion ([#1681](https://github.com/block/buzz/pull/1681)) ([`5e15a588e`](https://github.com/block/buzz/commit/5e15a588e072198b922fb3793a48d9c98146fc45)) +- feat(desktop): surface Team Instructions as distinct observer section ([#1759](https://github.com/block/buzz/pull/1759)) ([`56d822dc2`](https://github.com/block/buzz/commit/56d822dc2c9b78df50fad7928a8d088b7618e671)) +- fix(desktop): separate system prompt from turn context ([#1754](https://github.com/block/buzz/pull/1754)) ([`84ec63aa2`](https://github.com/block/buzz/commit/84ec63aa24d14553206dde5dbfc61807755313b8)) +- fix(desktop): preserve archived observer history ([#1752](https://github.com/block/buzz/pull/1752)) ([`51234bd89`](https://github.com/block/buzz/commit/51234bd89f1dffcd82bae45428094cff35c4081e)) +- fix(desktop): surface codex config-parse failures and -32603 internal errors clearly ([#1745](https://github.com/block/buzz/pull/1745)) ([`ec3762552`](https://github.com/block/buzz/commit/ec37625522ce1847e2684da2a477ca742255fbf1)) +- fix(desktop): fix workspace rail badge disappearance and persist mark-as-unread ([#1747](https://github.com/block/buzz/pull/1747)) ([`e8df6ba69`](https://github.com/block/buzz/commit/e8df6ba694703a26963524433e0e3a3f6ad252f1)) +- chore(desktop): remove container-only npm-preflight E2E harness ([#1749](https://github.com/block/buzz/pull/1749)) ([`9895b4d39`](https://github.com/block/buzz/commit/9895b4d390591a5adfc368bbc97f53989463c8ef)) +- fix(desktop): preflight npm prefix writability in doctor installs ([#1732](https://github.com/block/buzz/pull/1732)) ([`58aee8f91`](https://github.com/block/buzz/commit/58aee8f91394a97b68902f61a116696ed8ca3e1f)) +- fix(workspaces): exclude muted channels and unfollowed threads from rail unread badge ([#1738](https://github.com/block/buzz/pull/1738)) ([`ea1c75b0d`](https://github.com/block/buzz/commit/ea1c75b0d2bf954efe3816b303a9972e26ff893a)) +- feat(desktop): add explicit Edit avatar CTA in agent instance edit dialog ([#1736](https://github.com/block/buzz/pull/1736)) ([`26966aace`](https://github.com/block/buzz/commit/26966aacec1d8ca58ac5951818e55249e63506a3)) +- fix(observer): order system prompt below current-session divider on restart ([#1734](https://github.com/block/buzz/pull/1734)) ([`67a2d047e`](https://github.com/block/buzz/commit/67a2d047eb866dff3faae9f96b0a4f478e5e2a8b)) + + +## v0.4.0 + +- feat(desktop): default observer archive on for dev-nest builds ([#1726](https://github.com/block/buzz/pull/1726)) ([`d9e4edbbf`](https://github.com/block/buzz/commit/d9e4edbbf54996df22c59afdaaaaf571b4e3a5c6)) +- feat(desktop): thread baked build env into global agent config UI ([#1722](https://github.com/block/buzz/pull/1722)) ([`2f2ad409a`](https://github.com/block/buzz/commit/2f2ad409a76606dd9c54872dc518b0ecb72756af)) +- feat: relay invite links (mint + claim + landing page + deep link) ([#1668](https://github.com/block/buzz/pull/1668)) ([`2e529aab7`](https://github.com/block/buzz/commit/2e529aab759a18c1bb81e447f3696fe99db53a27)) +- fix(desktop): pass GlobalAgentConfig to spawn_config_hash in tests ([#1721](https://github.com/block/buzz/pull/1721)) ([`b97777350`](https://github.com/block/buzz/commit/b97777350dec375a1fe444891c8e10f9cee74d0c)) +- feat(desktop): global agent config defaults with readiness/spawn parity ([#1448](https://github.com/block/buzz/pull/1448)) ([`77c08365e`](https://github.com/block/buzz/commit/77c08365e4a66c0cc8731b66280ef029500bbfcf)) +- fix(desktop): remove "Remove all stopped" bulk action from agents menu ([#1720](https://github.com/block/buzz/pull/1720)) ([`651efe8d9`](https://github.com/block/buzz/commit/651efe8d95d11a591d8421973bf4352e36d69a3f)) +- fix(desktop): retire single-member built-in Fizz team ([#1718](https://github.com/block/buzz/pull/1718)) ([`f12bd3689`](https://github.com/block/buzz/commit/f12bd36893ab37f15261d47d2412732089e1645f)) +- fix(desktop): hide relay mesh option when mesh-llm feature is absent ([#1719](https://github.com/block/buzz/pull/1719)) ([`f0b8f806f`](https://github.com/block/buzz/commit/f0b8f806fde83b2ea8fc048e1076f4338e445545)) +- fix(desktop): propagate agent definition edits to instances and remove dead config knobs ([#1715](https://github.com/block/buzz/pull/1715)) ([`1f48860b7`](https://github.com/block/buzz/commit/1f48860b7b5454a07f4f066ccca665dc7ee26b20)) +- perf(desktop): thread panel rebuilt on every render — fix memo + add typing-latency benchmark ([#1652](https://github.com/block/buzz/pull/1652)) ([`9b2f0c8cb`](https://github.com/block/buzz/commit/9b2f0c8cb574f37eb38033f849b8420d15a63716)) +- fix(drafts): stop clearing drafts on workspace switch and hide sent section ([#1708](https://github.com/block/buzz/pull/1708)) ([`e2d104f66`](https://github.com/block/buzz/commit/e2d104f669468358ea917f1d7fa2dc9fc5958764)) +- fix(inbox): reply at same thread level as inbox item ([#1714](https://github.com/block/buzz/pull/1714)) ([`177fc30be`](https://github.com/block/buzz/commit/177fc30bea397b9422c25eb467c33496ebba9cbe)) +- fix(desktop): make @mention clicks reliably open the profile panel ([#1705](https://github.com/block/buzz/pull/1705)) ([`5f0d8309d`](https://github.com/block/buzz/commit/5f0d8309d266262965489eedea516f95a02fd410)) +- fix(desktop): walk ancestor chain in orphan sweep exemption ([#1711](https://github.com/block/buzz/pull/1711)) ([`7fb215c4f`](https://github.com/block/buzz/commit/7fb215c4fc29ecf2311f84d8f1f93b0301a582fa)) +- fix(desktop): stop showing /dev/null in diff labels, keep type badge visible ([#1704](https://github.com/block/buzz/pull/1704)) ([`868c9ad05`](https://github.com/block/buzz/commit/868c9ad05555dbc11d5c1ceece1e0a310bc3ab4a)) +- perf(desktop): stop re-rendering the app shell on every keystroke; fix two latent cache races it unmasked ([#1692](https://github.com/block/buzz/pull/1692)) ([`129c91a2b`](https://github.com/block/buzz/commit/129c91a2ba0017b20b65cbad8099269a383b46a4)) +- fix(observer): widen live subscription lookback to capture session/prompt ([#1710](https://github.com/block/buzz/pull/1710)) ([`715df2537`](https://github.com/block/buzz/commit/715df2537594da42c5b19406805c6c410c86a15f)) +- test(desktop): stabilize custom emoji e2e ([#1685](https://github.com/block/buzz/pull/1685)) ([`20c40d176`](https://github.com/block/buzz/commit/20c40d1769438a1b0fdcd032b3dcfdc79a683898)) +- fix(observer): use lifecycle item title as verb instead of hardcoded "Status" ([#1709](https://github.com/block/buzz/pull/1709)) ([`95bc640a5`](https://github.com/block/buzz/commit/95bc640a539c87c1fe0560d1dfa960b653bb96a1)) +- chore(desktop): remove dead archive-migration and keyring load_readonly helpers ([#1701](https://github.com/block/buzz/pull/1701)) ([`389a956e0`](https://github.com/block/buzz/commit/389a956e02e85b1a3379df384a14c6d9c449d3c6)) +- fix(desktop): stop losing imported identity on every launch ([#1568](https://github.com/block/buzz/pull/1568)) ([`e15f06857`](https://github.com/block/buzz/commit/e15f0685718bcba6d0947a8286ab2deeab0137ef)) +- fix(desktop): use augmented PATH for readiness probes ([#1613](https://github.com/block/buzz/pull/1613)) ([`94ec2c2bc`](https://github.com/block/buzz/commit/94ec2c2bc6f5505dbbbd4783c41f3bb20d5a642c)) +- fix(desktop): isolate dev keyring from production and make BUZZ_SHARE_IDENTITY keyring-aware ([#1680](https://github.com/block/buzz/pull/1680)) ([`e5d215e3c`](https://github.com/block/buzz/commit/e5d215e3c2f4939fe4d0e1f09c8f9a265f818385)) +- fix(desktop): migrate Databricks V1→V2 records at boot and fix readiness gate ([#1686](https://github.com/block/buzz/pull/1686)) ([`1f2afce29`](https://github.com/block/buzz/commit/1f2afce29d46ec09f63d467af4cacdd479a9a336)) +- B5 residue: inbound-event security hardening + persona→agent copy sweep ([#1688](https://github.com/block/buzz/pull/1688)) ([`f0e65589a`](https://github.com/block/buzz/commit/f0e65589a9a68cd19457e1196b8eb4db68217a3b)) +- fix(desktop): paginate complete channel directory ([#1690](https://github.com/block/buzz/pull/1690)) ([`b41cf3ffc`](https://github.com/block/buzz/commit/b41cf3ffc6d232dc1199f7563bd3c4da8571bfe1)) +- fix: flush debounce on Tab/Enter to prevent stale autocomplete ([#1661](https://github.com/block/buzz/pull/1661)) ([`841282141`](https://github.com/block/buzz/commit/841282141b5feb728dd111c9163cd0060f9bbb86)) +- fix(desktop): preserve agent timer state across workspace switches ([#1679](https://github.com/block/buzz/pull/1679)) ([`46613dcce`](https://github.com/block/buzz/commit/46613dccec89b064b2ba61d65b191c155a5aac3f)) +- fix(desktop): honor macOS title-bar double-click preference ([#1674](https://github.com/block/buzz/pull/1674)) ([`6b2147a5d`](https://github.com/block/buzz/commit/6b2147a5d18d70a3a46d2484d746de3506f8138d)) +- feat(desktop): projects overview v2 — aggregate rail, PR review flow, commit detail, and straighter layout ([#1677](https://github.com/block/buzz/pull/1677)) ([`58fe9388c`](https://github.com/block/buzz/commit/58fe9388c81ace47001cba55f29afb76f288f3e0)) +- B5: one create path — behavioral quad activation, backfill, menu collapse, legacy dialog deletion ([#1667](https://github.com/block/buzz/pull/1667)) ([`3a7e40027`](https://github.com/block/buzz/commit/3a7e4002787ea3e8f0f91a62fdbd2f1eb3e7a49f)) +- Polish Buzz sidebar theme ([#1671](https://github.com/block/buzz/pull/1671)) ([`cdba2a08e`](https://github.com/block/buzz/commit/cdba2a08e1645f63e71965771cfbbf4a1437f169)) +- fix(agents): harden structured error-code classification edge cases ([#1663](https://github.com/block/buzz/pull/1663)) ([`fc8a17445`](https://github.com/block/buzz/commit/fc8a17445a5712d0a1456684d78952edf41cf5ae)) +- feat: add deeplink nostr identity binding flow ([#1648](https://github.com/block/buzz/pull/1648)) ([`cecd03142`](https://github.com/block/buzz/commit/cecd031428358d7b3aa0326ccca74649bd928231)) +- perf(desktop): cache parsed markdown across channel-switch remounts ([#1635](https://github.com/block/buzz/pull/1635)) ([`2b3246934`](https://github.com/block/buzz/commit/2b324693404623b2fa2e53ab6ec570977526ace3)) +- fix(agents): surface provider errors structurally instead of raw dumps ([#1653](https://github.com/block/buzz/pull/1653)) ([`9b244ff22`](https://github.com/block/buzz/commit/9b244ff22b2fdab1d4aa20fcf661d22aa0cf84aa)) +- feat(relay,desktop): canonicalize agent definitions on kind:30175 (Phase 2) ([#1655](https://github.com/block/buzz/pull/1655)) ([`b2c63291f`](https://github.com/block/buzz/commit/b2c63291f1e432951f49c57bd1ec65812870f515)) +- fix(desktop): scope observer feed by channel and add session-boundary dividers ([#1634](https://github.com/block/buzz/pull/1634)) ([`fd5d04de0`](https://github.com/block/buzz/commit/fd5d04de0593ee8c335b57bd2d248c638b055ade)) +- feat(config-nudge): distinguish install/auth state in cli_login nudge cards ([#1633](https://github.com/block/buzz/pull/1633)) ([`4f1a487ab`](https://github.com/block/buzz/commit/4f1a487ab49531d4ebe284fd3cee689c13651c7f)) +- fix(desktop): async-ify the auto-restart setter after the perf-sweep merge collision ([#1651](https://github.com/block/buzz/pull/1651)) ([`dc7ececdc`](https://github.com/block/buzz/commit/dc7ececdccd8ed5675f1560a5319518ade6b5e8e)) +- feat(desktop): auto-restart agents on config change (Chunk F) ([#1649](https://github.com/block/buzz/pull/1649)) ([`a415f0f31`](https://github.com/block/buzz/commit/a415f0f3109fae970d266474fec64f16bab53b33)) +- perf(desktop): GUI performance sweep — async offload, poll reduction, render stabilization ([#1641](https://github.com/block/buzz/pull/1641)) ([`2cc0eb539`](https://github.com/block/buzz/commit/2cc0eb53968ec6281be0e6778a06ea18e044395e)) +- fix: user search returns zero results for partially typed names ([#1603](https://github.com/block/buzz/pull/1603)) ([`11608ef67`](https://github.com/block/buzz/commit/11608ef675ef11c375b2ac9c04bdcc287affeb46)) +- copy(desktop): finish the persona→agent rename in rendered UI (B4.1) ([#1647](https://github.com/block/buzz/pull/1647)) ([`577fc8df5`](https://github.com/block/buzz/commit/577fc8df5254f8fc1c5ec260d22f64f8786c6244)) +- Community moderation UI: integration branch (data layer + member surface base) ([#1617](https://github.com/block/buzz/pull/1617)) ([`c9c868b25`](https://github.com/block/buzz/commit/c9c868b250e1dc8f6019abaaa6ae2c203294dfc4)) +- Community moderation Phase 1: reports, bans/timeouts, audit, tombstones, relay-DM notices ([#1616](https://github.com/block/buzz/pull/1616)) ([`863aeb79f`](https://github.com/block/buzz/commit/863aeb79f35a43222bd5de3901ab06220948fb9d)) +- copy(desktop): the word "persona" leaves the UI (Phase 1B.4) ([#1646](https://github.com/block/buzz/pull/1646)) ([`41b187f93`](https://github.com/block/buzz/commit/41b187f93646403689311d5f5bfcbc4cc1bf4927)) +- feat(desktop): flapping bee on the setup loading screen ([#1631](https://github.com/block/buzz/pull/1631)) ([`c145037aa`](https://github.com/block/buzz/commit/c145037aad121bb9f6fd6e9068ff3cf77fb86a48)) +- fix(desktop): make ⌘K open the composer link editor for selections ([#1644](https://github.com/block/buzz/pull/1644)) ([`3e982fd37`](https://github.com/block/buzz/commit/3e982fd371b97aebf39a3e418e323e6c0de7b665)) +- fix(linux): make AppImage work on Mesa 25+ / GLib 2.88 distros ([#1567](https://github.com/block/buzz/pull/1567)) ([`563dab92e`](https://github.com/block/buzz/commit/563dab92e4f07b87db0c4156a20b0041c60b561c)) +- fix(desktop): add unread dot to workspace rail for channel unreads ([#1637](https://github.com/block/buzz/pull/1637)) ([`f968601ef`](https://github.com/block/buzz/commit/f968601ef6208ce411989617d15b2f3592a51665)) +- refactor(desktop): converge the three definition→instance mappings (Phase 1B.3.5) ([#1645](https://github.com/block/buzz/pull/1645)) ([`9683ef807`](https://github.com/block/buzz/commit/9683ef807919f4d04f960280bff3b9d99da12af3)) +- Hide non-invocable member agents from @-mention autocomplete ([#1611](https://github.com/block/buzz/pull/1611)) ([`f929aa09e`](https://github.com/block/buzz/commit/f929aa09e685eece9ff710c7506b07834df1f00e)) +- fix(desktop): clamp HDR avatar images to SDR range ([#1642](https://github.com/block/buzz/pull/1642)) ([`fa816d5bf`](https://github.com/block/buzz/commit/fa816d5bfade9eb84e93b720bfc55e6af4e623f4)) +- refactor(desktop): definition-edit routes through AgentDialog — single dialog entry point (Phase 1B.3c) ([#1643](https://github.com/block/buzz/pull/1643)) ([`aa17294ad`](https://github.com/block/buzz/commit/aa17294ad07c0fa64b94502e9e9a07aa9bd5c70d)) +- fix(desktop): refresh persona env vars on respawn — record.env_vars is overrides-only ([#1640](https://github.com/block/buzz/pull/1640)) ([`79a057534`](https://github.com/block/buzz/commit/79a0575342016b8aebeff322e84c9cea0bf76443)) +- refactor(desktop): re-host instance edit as AgentInstanceEditDialog behind AgentDialog (Phase 1B.3b) ([#1639](https://github.com/block/buzz/pull/1639)) ([`8af18ee32`](https://github.com/block/buzz/commit/8af18ee32515db20ec5b4d3d430a49f702209cdc)) +- feat(desktop): show full URL tooltip on masked link hover ([#1625](https://github.com/block/buzz/pull/1625)) ([`01c6b8566`](https://github.com/block/buzz/commit/01c6b8566e7d6ccd291c4a244285927476196b6d)) +- fix(desktop): paint Buzz gradient on the workspace rail ([#1636](https://github.com/block/buzz/pull/1636)) ([`2f1cd6771`](https://github.com/block/buzz/commit/2f1cd677147b5d07979cef112c728a96a4dff4f1)) +- refactor(desktop): single-home the definition form as AgentDefinitionDialog (Phase 1B.3a) ([#1627](https://github.com/block/buzz/pull/1627)) ([`48bd8abfe`](https://github.com/block/buzz/commit/48bd8abfeb165f23f95d456be91b45c4b698a0df)) +- feat(desktop): unified AgentDialog create entry point with create intents (Phase 1B.2) ([#1626](https://github.com/block/buzz/pull/1626)) ([`6f38db680`](https://github.com/block/buzz/commit/6f38db68039300d7f92122b9d7631b5a4bbe5e53)) +- Update DMG background and label size ([#1629](https://github.com/block/buzz/pull/1629)) ([`5b1a53ceb`](https://github.com/block/buzz/commit/5b1a53ceba2c6d9196a52b478c1f581c4d42b8df)) +- test(desktop): deflake mid-scroll older-history spinner smoke test ([#1632](https://github.com/block/buzz/pull/1632)) ([`7ff58d9af`](https://github.com/block/buzz/commit/7ff58d9af9edbdfd3f1a8c508641dda12710320a)) +- feat(desktop): add Buzz light + dark themes with branded sidebar gradient ([#1630](https://github.com/block/buzz/pull/1630)) ([`290be2a03`](https://github.com/block/buzz/commit/290be2a0395c1d2151ae5524c5474d95cffc302c)) +- fix(desktop): render thread facepile oldest-replier-first ([#1595](https://github.com/block/buzz/pull/1595)) ([`f9701a386`](https://github.com/block/buzz/commit/f9701a38600302b3a97845b6f25681b6ef63dbd7)) +- fix(desktop): keep sidebar section actions visible while ⋮ menu is open ([#1584](https://github.com/block/buzz/pull/1584)) ([`943d7a663`](https://github.com/block/buzz/commit/943d7a663028a32485f3a372a1ae73286c225e95)) +- refactor(desktop): extract shared runtime/provider/model selection + env-var helpers (Phase 1B.1) ([#1624](https://github.com/block/buzz/pull/1624)) ([`4b5cac074`](https://github.com/block/buzz/commit/4b5cac074b10dab75404aa09f65d5a4e43ff4848)) +- feat(desktop): fold personas.json into the unified agent store (Phase 1A.2) ([#1623](https://github.com/block/buzz/pull/1623)) ([`381c7714f`](https://github.com/block/buzz/commit/381c7714fa6943f033eb3963aa105397098b782b)) +- fix(desktop): hash spawn config as the env receives it, not raw record fields ([#1621](https://github.com/block/buzz/pull/1621)) ([`f0eb4eccd`](https://github.com/block/buzz/commit/f0eb4eccd9dbae6819d44c282db675c89f8e972a)) +- test(desktop): add edit-agent dialog e2e coverage (Phase 1B.3b-pre) ([#1628](https://github.com/block/buzz/pull/1628)) ([`a609e796b`](https://github.com/block/buzz/commit/a609e796bd1b5eda01c733041e5e5320c2f4557d)) +- feat(desktop): unified AgentRecord groundwork — record-first resolution + runtime materialization (Phase 1A.1) ([#1618](https://github.com/block/buzz/pull/1618)) ([`5dce80aff`](https://github.com/block/buzz/commit/5dce80aff53ca698c1c17ac21866bc3949ed14bc)) +- fix(desktop): backfill edits for thread replies so edited replies survive refetch ([#1610](https://github.com/block/buzz/pull/1610)) ([`d1f3194e7`](https://github.com/block/buzz/commit/d1f3194e73abd1389c62b32e0502fcf8c5ce9b4c)) +- chore(desktop): clear desktop-tauri clippy backlog ([#1612](https://github.com/block/buzz/pull/1612)) ([`36fd41c5c`](https://github.com/block/buzz/commit/36fd41c5ce63bf0f8f0ad8d740fce47cae7ef9d1)) +- fix(desktop): restrict shared-agent sync to dev data dirs ([#1597](https://github.com/block/buzz/pull/1597)) ([`e5f831d2c`](https://github.com/block/buzz/commit/e5f831d2c2358592e24a20fb020f43fc50ea9d1b)) +- feat(desktop): restart-required badge from spawn-time config hash ([#1602](https://github.com/block/buzz/pull/1602)) ([`cc42a4979`](https://github.com/block/buzz/commit/cc42a497991ee6fe632734239c53a7c4d4fb4424)) +- feat(desktop): boot-time reconcile of managed agents to relay events ([#1601](https://github.com/block/buzz/pull/1601)) ([`c5a541ee2`](https://github.com/block/buzz/commit/c5a541ee2d2436d0fb970e5c0164bed349a76c4a)) +- feat(desktop): canonical component — hover to view/copy full keys, owner "you" labels ([#1589](https://github.com/block/buzz/pull/1589)) ([`777babf39`](https://github.com/block/buzz/commit/777babf3938a2ae7ef97fd12f12e6247e7c18cec)) +- fix(desktop): hydrate reactions for Inbox context messages ([#1596](https://github.com/block/buzz/pull/1596)) ([`cdf982bdb`](https://github.com/block/buzz/commit/cdf982bdb3fa4693416104a3e26a324e7703b58c)) + + +## v0.3.46 + +- fix(desktop): preserve agent model/provider when persona snapshot fields are blank ([#1583](https://github.com/block/buzz/pull/1583)) ([`a3ee2c569`](https://github.com/block/buzz/commit/a3ee2c5693d58f32b17072a49add2dbaff8e0ffd)) +- feat(acp,desktop): identify and reap stale agent harness processes ([#1582](https://github.com/block/buzz/pull/1582)) ([`3c6c0d447`](https://github.com/block/buzz/commit/3c6c0d4478e5bb6e734cc16eafa6de6a433bf8fe)) +- feat(desktop): active-draft badge, send-from-drafts confirm dialog, thread-deleted state ([#1581](https://github.com/block/buzz/pull/1581)) ([`b394e95ef`](https://github.com/block/buzz/commit/b394e95ef03b131fb43bda0954d900ae56473f31)) +- fix(desktop): treat baked build env vars as satisfying required agent config ([#1580](https://github.com/block/buzz/pull/1580)) ([`b90e80443`](https://github.com/block/buzz/commit/b90e80443ea498e2b7b97bde9069d4b7c6ab4048)) +- feat(desktop): add "Copy image" to image right-click context menu ([#1579](https://github.com/block/buzz/pull/1579)) ([`683f7fec1`](https://github.com/block/buzz/commit/683f7fec1c357f1140a4f874720f513b69d919a4)) +- fix(nest): use buzz-dev symlink name for dev builds ([#1587](https://github.com/block/buzz/pull/1587)) ([`dcbb3ff78`](https://github.com/block/buzz/commit/dcbb3ff7892891cdc3c447049444cd2be8570f5e)) +- fix(composer): address image-editor follow-up nits on #1491 ([#1565](https://github.com/block/buzz/pull/1565)) ([`49391d807`](https://github.com/block/buzz/commit/49391d80713b74d1f48a442b193d9a91b086d99a)) +- fix(desktop): render black static boot screen ([#1570](https://github.com/block/buzz/pull/1570)) ([`10444d248`](https://github.com/block/buzz/commit/10444d248e26f6ab119cb13ccef906f42dc333da)) +- feat(agents): group activity tool bursts ([#1571](https://github.com/block/buzz/pull/1571)) ([`426f04484`](https://github.com/block/buzz/commit/426f044844951fc1ced7670d2dd5d25812deced9)) +- feat(desktop): aggregated overview rail, commit detail page, and full breadcrumbs ([#1573](https://github.com/block/buzz/pull/1573)) ([`c97263901`](https://github.com/block/buzz/commit/c97263901f9757ed6e9ddbf3f47097e2c6567e24)) +- fix(desktop): fetch profiles for reaction actors and thread-reply authors ([#1550](https://github.com/block/buzz/pull/1550)) ([`62def1459`](https://github.com/block/buzz/commit/62def1459c6e4d2beb5fda9e0c4accde16aa2c8d)) +- refactor(desktop): unify EditAgentDialog styling with PersonaDialog ([#1540](https://github.com/block/buzz/pull/1540)) ([`c258ffc4a`](https://github.com/block/buzz/commit/c258ffc4a87fb523900801d0959334912f25d30a)) +- feat(desktop): add 10-minute message grouping window ([#1578](https://github.com/block/buzz/pull/1578)) ([`564ead385`](https://github.com/block/buzz/commit/564ead3856c1e9f5c8760aaa6bcc9bd16cbbd9be)) +- feat(desktop): unify sidebar section actions into a per-section ⋮ menu ([#1577](https://github.com/block/buzz/pull/1577)) ([`d94561795`](https://github.com/block/buzz/commit/d94561795a48af743278bfa1e912b894228312f3)) +- feat(desktop): emoji avatar picker for agents + reliable picker scroll ([#1576](https://github.com/block/buzz/pull/1576)) ([`ed84a8735`](https://github.com/block/buzz/commit/ed84a87358495a4cdd6e61ee1cfe01db9a367b35)) +- fix(desktop): sync derived active-turn liveness in channel activity path ([#1492](https://github.com/block/buzz/pull/1492)) ([`1f5ba5bb2`](https://github.com/block/buzz/commit/1f5ba5bb27a94d33cce5aaa7cfea1d116c7d582d)) +- fix(personas): remove goose runtime pin from Fizz built-in ([#1566](https://github.com/block/buzz/pull/1566)) ([`e3008b360`](https://github.com/block/buzz/commit/e3008b3604330803975d66920204e7365fc571a9)) +- feat(acp,buzz-agent): thread model name through NIP-AM kind 44200 emit path ([#1564](https://github.com/block/buzz/pull/1564)) ([`08e707eb5`](https://github.com/block/buzz/commit/08e707eb573a187432a950154a15f9cb4785d25f)) +- sync channel sort preferences across clients ([#1556](https://github.com/block/buzz/pull/1556)) ([`04a9d1029`](https://github.com/block/buzz/commit/04a9d10299b92c5b0e7597a53914b9a753985c51)) +- fix desktop canvas header blur in split channel management ([#1558](https://github.com/block/buzz/pull/1558)) ([`2ef26ec21`](https://github.com/block/buzz/commit/2ef26ec21048857fc11982cd8c153e1c795f2c6d)) +- feat(composer): compose spoiler, annotation, and tooltip controls ([#1491](https://github.com/block/buzz/pull/1491)) ([`1aa87bb26`](https://github.com/block/buzz/commit/1aa87bb26b96d13fa2bba042ccd933e988d79bae)) +- feat(channel list): add persistent channel sort toggle ([#1505](https://github.com/block/buzz/pull/1505)) ([`36ac5243f`](https://github.com/block/buzz/commit/36ac5243ff8aed4401d3453a115afc4a603fccf0)) + + +## v0.3.45 + +- chore(deps): bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 ([#1563](https://github.com/block/buzz/pull/1563)) ([`75b52ad7e`](https://github.com/block/buzz/commit/75b52ad7e54ca7f930656e756910a08d0f146b82)) +- fix(archive): atomic remove-kind path + split test modules ([#1562](https://github.com/block/buzz/pull/1562)) ([`484bede75`](https://github.com/block/buzz/commit/484bede7597814dd7dc322d1f72f69c08545ef42)) +- feat(archive): add agent turn-metric (kind 44200) local archive ([#1555](https://github.com/block/buzz/pull/1555)) ([`429f54b4f`](https://github.com/block/buzz/commit/429f54b4f45df0d5f4a52aafb8913e903ebdc6b3)) +- feat(desktop,buzz-acp): add harness-agnostic config bridge and setup-listener mode ([#1411](https://github.com/block/buzz/pull/1411)) ([`d8355d318`](https://github.com/block/buzz/commit/d8355d318853f85ffaf53dc7b5215bc998b460c4)) +- fix(desktop): stop edit-channel dialog hanging on "Saving..." ([#1557](https://github.com/block/buzz/pull/1557)) ([`76806cb1f`](https://github.com/block/buzz/commit/76806cb1f654650a412b7f0368f495062a9ee224)) +- feat(archive): add local-save archive with observer-feed default-on option ([#1442](https://github.com/block/buzz/pull/1442)) ([`711078807`](https://github.com/block/buzz/commit/711078807777db77960404e51ffd862c08a33ded)) +- feat(desktop): add Slack-like Drafts inbox with persistence and image fix ([#1539](https://github.com/block/buzz/pull/1539)) ([`4c598dcef`](https://github.com/block/buzz/commit/4c598dcef577bb204f9666eaac5b95743a0f8736)) +- fix(setup): fix syntax error in seed-local-community.sh ([#1547](https://github.com/block/buzz/pull/1547)) ([`3729a6515`](https://github.com/block/buzz/commit/3729a6515617abdae03117d9713dfeb7e1ff74c2)) +- feat(desktop): add right-click context menu to workspace rail ([#1552](https://github.com/block/buzz/pull/1552)) ([`0e87998f8`](https://github.com/block/buzz/commit/0e87998f8fcac0ff1612d661cbd505a63b51c550)) +- fix(desktop): restore saved window geometry on launch ([#1554](https://github.com/block/buzz/pull/1554)) ([`e790c9828`](https://github.com/block/buzz/commit/e790c9828f1ae85ce7618d06e67e78732be4221b)) +- feat(desktop): add typeahead search to persona model dropdown ([#1542](https://github.com/block/buzz/pull/1542)) ([`3b43743e1`](https://github.com/block/buzz/commit/3b43743e1f2acd192705a80629ceadfa7a737831)) +- fix(desktop): publish new agent profiles to active relay ([#1546](https://github.com/block/buzz/pull/1546)) ([`346efe08b`](https://github.com/block/buzz/commit/346efe08b3ca6e847b6c9c2f843cdb8834db123c)) +- chore(desktop): use dot access for host header assignment ([#1548](https://github.com/block/buzz/pull/1548)) ([`ccfdf45bc`](https://github.com/block/buzz/commit/ccfdf45bc5dd61ef7d59644293d84deed24e1789)) +- fix(onboarding): guard against webkit2gtk WAL race with explicit profile-event signal ([#1508](https://github.com/block/buzz/pull/1508)) ([`c2ee4d162`](https://github.com/block/buzz/commit/c2ee4d162753a3338dc434f85c80a82eb284b3d2)) + + +## v0.3.44 + +- fix(desktop): keep thread-summary badges mounted through scrollback prepends ([#1533](https://github.com/block/buzz/pull/1533)) ([`6abf614fd`](https://github.com/block/buzz/commit/6abf614fd4eb512cf2e4175f17dc09892a8b472d)) +- refactor(shell): drop bundled PortableGit, add BUZZ_SHELL override + dialect hint ([#1536](https://github.com/block/buzz/pull/1536)) ([`cfa208983`](https://github.com/block/buzz/commit/cfa20898313080b7a106ae0e092d8ed6815daeb0)) +- feat(nips,relay,acp): NIP-AM durable encrypted agent turn metrics (kind 44200) ([#1441](https://github.com/block/buzz/pull/1441)) ([`71265ca36`](https://github.com/block/buzz/commit/71265ca36105dbf62453a99c998c3f3dd134a304)) + + ## v0.3.43 - fix(desktop): sync agent relay profile when persona avatar changes ([#1512](https://github.com/block/buzz/pull/1512)) ([`ce901f7c1`](https://github.com/block/buzz/commit/ce901f7c1e548a0aab0d9e97f4951ea134f6b3d2)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3f00e7b30..a684f84bb7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,9 +110,15 @@ just desktop-dev # terminal 2 — Vite dev server only (no Tauri shell) ```bash just down # Stop Docker services, keep data -just reset # ⚠️ Wipe all data and recreate the environment +just reset # Wipe all dev state and recreate it; installed Buzz is preserved ``` +Development desktop state uses separate bundle identifiers +(`xyz.block.buzz.app.dev` and per-worktree variants), a separate keyring service +(`buzz-desktop-dev`), and `~/.buzz-dev`. `just reset` removes those dev-only +locations and the local Docker volumes. It does not touch the installed app's +`xyz.block.buzz.app` data, `buzz-desktop` keyring service, or `~/.buzz` nest. + --- ## Running Tests diff --git a/Cargo.lock b/Cargo.lock index 1198e7f0f0..d54fc64c0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,7 +103,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -114,7 +114,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -123,6 +123,21 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "appattest" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7" +dependencies = [ + "arrayvec", + "aws-lc-rs", + "base64", + "byteorder", + "minicbor", + "rustls-pki-types", + "rustls-webpki", +] + [[package]] name = "approx" version = "0.5.1" @@ -271,7 +286,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -306,7 +321,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -360,6 +375,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-destructor" version = "0.3.0" @@ -505,7 +529,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -554,15 +578,30 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -588,9 +627,15 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -695,7 +740,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -731,7 +776,7 @@ dependencies = [ "hex", "nix 0.31.3", "nostr", - "reqwest 0.13.3", + "reqwest 0.13.4", "rustls", "serde", "serde_json", @@ -783,7 +828,7 @@ dependencies = [ "getrandom 0.4.2", "hex", "nix 0.31.3", - "reqwest 0.13.3", + "reqwest 0.13.4", "rmcp", "serde", "serde_json", @@ -842,12 +887,14 @@ dependencies = [ "buzz-persona", "buzz-sdk", "buzz-ws-client", + "bytes", + "chrono", "clap", "diffy", "hex", "infer", "nostr", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", @@ -912,14 +959,16 @@ version = "0.1.0" dependencies = [ "base64", "buzz-cli", + "buzz-core", "git-credential-nostr", "git-sign-nostr", "ignore", "image", "nix 0.31.3", "nostr", - "reqwest 0.13.3", + "reqwest 0.13.4", "rmcp", + "rustls", "schemars", "serde", "serde_json", @@ -959,6 +1008,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "ulid", "uuid", ] @@ -1026,11 +1076,47 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-push-gateway" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "appattest", + "async-trait", + "axum", + "base64", + "byteorder", + "chrono", + "getrandom 0.4.2", + "hex", + "metrics", + "metrics-exporter-prometheus", + "minicbor", + "nostr", + "p256", + "proptest", + "rand 0.10.1", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "buzz-relay" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", + "async-trait", "axum", "base64", "buzz-audit", @@ -1040,6 +1126,7 @@ dependencies = [ "buzz-db", "buzz-media", "buzz-pubsub", + "buzz-relay-mesh", "buzz-sdk", "buzz-search", "buzz-workflow", @@ -1055,14 +1142,16 @@ dependencies = [ "mesh-llm-sdk", "metrics", "metrics-exporter-prometheus", + "metrics-util", "moka", "nostr", "opentelemetry 0.32.0", "opentelemetry-otlp 0.32.0", "opentelemetry_sdk 0.32.1", + "postcard", "rand 0.10.1", "redis", - "reqwest 0.13.3", + "reqwest 0.13.4", "rust-s3", "rustls", "serde", @@ -1084,6 +1173,29 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-relay-mesh" +version = "0.1.0" +dependencies = [ + "bytes", + "deadpool-redis", + "futures-util", + "hex", + "hmac 0.13.0", + "iroh", + "nostr", + "postcard", + "proptest", + "redis", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "buzz-sdk" version = "0.1.0" @@ -1121,7 +1233,7 @@ dependencies = [ "hex", "nostr", "rand 0.10.1", - "reqwest 0.13.3", + "reqwest 0.13.4", "rust-s3", "rustls", "serde", @@ -1149,7 +1261,7 @@ dependencies = [ "hex", "moka", "nostr", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_yaml", @@ -1173,6 +1285,12 @@ dependencies = [ "url", ] +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.0" @@ -1227,6 +1345,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfb" version = "0.7.3" @@ -1347,7 +1471,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1428,6 +1552,20 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if 1.0.4", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -1581,6 +1719,12 @@ dependencies = [ "futures-io", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1662,9 +1806,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -1690,7 +1834,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.13.0", "crossterm_winapi", "mio", "parking_lot", @@ -1700,6 +1844,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -1715,6 +1877,22 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1732,7 +1910,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.2", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1764,6 +1944,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "csv" version = "1.4.0" @@ -1817,6 +2007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -1836,12 +2027,12 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "5.0.0-pre.6" +version = "5.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" dependencies = [ "cfg-if 1.0.4", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto 0.3.0", @@ -1860,7 +2051,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1894,7 +2085,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -1907,7 +2098,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -1918,7 +2109,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1929,7 +2120,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1969,7 +2160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn", + "syn 1.0.109", ] [[package]] @@ -2032,6 +2223,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.8.0" @@ -2061,7 +2258,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2082,7 +2279,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2092,7 +2289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", ] [[package]] @@ -2114,7 +2311,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -2183,7 +2380,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -2197,7 +2394,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2220,6 +2417,15 @@ dependencies = [ "const-random", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -2253,6 +2459,21 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + [[package]] name = "ed25519" version = "3.0.0" @@ -2266,11 +2487,11 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "3.0.0-pre.7" +version = "3.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" dependencies = [ - "curve25519-dalek 5.0.0-pre.6", + "curve25519-dalek 5.0.0-rc.0", "ed25519", "rand_core 0.10.1", "serde", @@ -2289,6 +2510,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -2316,6 +2558,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "enum-assoc" version = "1.3.0" @@ -2324,7 +2572,7 @@ checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2345,7 +2593,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2374,6 +2622,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "evalexpr" version = "11.3.1" @@ -2412,6 +2669,22 @@ dependencies = [ "smallvec", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" @@ -2427,6 +2700,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2439,6 +2722,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2455,6 +2749,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2634,7 +2940,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2686,8 +2992,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -2804,7 +3110,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2832,6 +3138,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.14" @@ -2950,7 +3267,7 @@ dependencies = [ "hf-xet", "hyper", "pathdiff", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", @@ -3002,7 +3319,7 @@ dependencies = [ "http", "idna", "ipnet", - "jni", + "jni 0.22.4", "rand 0.10.1", "rustls", "thiserror 2.0.18", @@ -3022,7 +3339,7 @@ dependencies = [ "data-encoding", "idna", "ipnet", - "jni", + "jni 0.22.4", "once_cell", "prefix-trie", "rand 0.10.1", @@ -3045,7 +3362,7 @@ dependencies = [ "hickory-proto", "ipconfig", "ipnet", - "jni", + "jni 0.22.4", "moka", "ndk-context", "once_cell", @@ -3165,11 +3482,13 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -3502,6 +3821,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -3521,6 +3849,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "instant" version = "0.1.13" @@ -3557,9 +3898,9 @@ dependencies = [ [[package]] name = "iroh" -version = "1.0.0-rc.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98e206e3d3f2642f5c08c413755fc0ac19b54ae1a656af88be03454ce3ed2e6" +checksum = "5fca9b4b462c343ff88fc0af4096c186f939b602a0bc08723536ef2c31c93971" dependencies = [ "backon", "blake3", @@ -3590,11 +3931,10 @@ dependencies = [ "portable-atomic", "portmapper", "rand 0.10.1", - "reqwest 0.13.3", + "reqwest 0.13.4", "rustc-hash", "rustls", "rustls-pki-types", - "rustls-webpki", "serde", "smallvec", "strum", @@ -3605,36 +3945,32 @@ dependencies = [ "tracing", "url", "wasm-bindgen-futures", - "webpki-roots 1.0.7", ] [[package]] name = "iroh-base" -version = "1.0.0-rc.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af93d67701c00c504982154569192ad384738c0450ba1196930314b955100552" +checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d" dependencies = [ - "curve25519-dalek 5.0.0-pre.6", + "curve25519-dalek 5.0.0-rc.0", "data-encoding", "data-encoding-macro", "derive_more", - "digest 0.11.3", "ed25519-dalek", "getrandom 0.4.2", "n0-error", "rand 0.10.1", "serde", - "sha2 0.11.0", "url", "zeroize", - "zeroize_derive", ] [[package]] name = "iroh-dns" -version = "1.0.0-rc.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de4112c91eb64094d77df9d3112606dcf7ff216421afccd2dc762fda5a7b2879" +checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" dependencies = [ "arc-swap", "cfg_aliases", @@ -3644,8 +3980,8 @@ dependencies = [ "n0-error", "n0-future", "ndk-context", + "portable-atomic", "rand 0.10.1", - "reqwest 0.13.3", "rustls", "simple-dns", "strum", @@ -3656,9 +3992,9 @@ dependencies = [ [[package]] name = "iroh-metrics" -version = "1.0.0-rc.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d102597d0ee523f17fdb672c532395e634dbe945429284c811430d63bacc0d8a" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" dependencies = [ "iroh-metrics-derive", "itoa", @@ -3671,21 +4007,21 @@ dependencies = [ [[package]] name = "iroh-metrics-derive" -version = "1.0.0-rc.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c8e0c97f1dc787107f388433c349397c565572fe6406d600ff7bb7b7fe3b30" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "iroh-relay" -version = "1.0.0-rc.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54f490405e42dd2ecf16be18a3587d2665401e94a498094f12322eaa6d5ebb2b" +checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" dependencies = [ "blake3", "bytes", @@ -3710,7 +4046,7 @@ dependencies = [ "pin-project", "postcard", "rand 0.10.1", - "reqwest 0.13.3", + "reqwest 0.13.4", "rustls", "rustls-pki-types", "serde", @@ -3748,6 +4084,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if 1.0.4", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -3757,7 +4109,7 @@ dependencies = [ "cfg-if 1.0.4", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.18", @@ -3775,7 +4127,16 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", ] [[package]] @@ -3794,7 +4155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3829,6 +4190,17 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keyring" version = "3.6.3" @@ -3863,6 +4235,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -3912,6 +4290,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.17" @@ -3931,6 +4315,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3949,6 +4342,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -4013,6 +4412,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "matchers" version = "0.2.0" @@ -4036,7 +4445,7 @@ checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4076,6 +4485,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.9.1" @@ -4087,8 +4502,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "hex", "mesh-llm-client", @@ -4097,8 +4512,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4106,10 +4521,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "mesh-llm-build-info" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" + [[package]] name = "mesh-llm-client" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "async-trait", @@ -4140,8 +4560,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "dirs", @@ -4151,12 +4571,13 @@ dependencies = [ "skippy-protocol", "toml 0.9.12+spec-1.1.0", "toml_edit", + "url", ] [[package]] name = "mesh-llm-embedded-runtime" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -4165,18 +4586,20 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "clap", + "crossterm 0.28.1", + "ratatui", "serde_json", ] [[package]] name = "mesh-llm-gpu-bench" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "cc", @@ -4188,8 +4611,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "serde", "serde_json", @@ -4197,16 +4620,16 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "mesh-llm-native-runtime", ] [[package]] name = "mesh-llm-host-runtime" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "argon2", @@ -4217,7 +4640,7 @@ dependencies = [ "chacha20poly1305", "chrono", "clap", - "crossterm", + "crossterm 0.28.1", "crypto_box", "dirs", "ed25519-dalek", @@ -4228,12 +4651,14 @@ dependencies = [ "http", "http-body-util", "httparse", + "if-addrs", "iroh", "json5", "keyring", "libc", "mdns-sd", "mesh-llm-api-server", + "mesh-llm-build-info", "mesh-llm-client", "mesh-llm-config", "mesh-llm-events", @@ -4278,6 +4703,7 @@ dependencies = [ "skippy-runtime", "skippy-server", "skippy-topology", + "socket2", "tabwriter", "tar", "tempfile", @@ -4295,8 +4721,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "argon2", "base64", @@ -4317,8 +4743,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "serde", @@ -4328,8 +4754,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "mesh-llm-types", @@ -4342,8 +4768,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "async-trait", @@ -4359,8 +4785,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "dirs", @@ -4377,8 +4803,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "hex", @@ -4390,22 +4816,23 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "dirs", "flate2", "futures-util", "hex", + "mesh-llm-build-info", "mesh-llm-hardware-profile", "mesh-llm-native-runtime", "reqwest 0.12.28", @@ -4420,8 +4847,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4435,8 +4862,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "dirs", @@ -4446,8 +4873,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "chrono", @@ -4455,6 +4882,7 @@ dependencies = [ "dirs", "hex", "libc", + "mesh-llm-build-info", "mesh-llm-gpu-bench", "reqwest 0.12.28", "semver", @@ -4468,8 +4896,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "hex", "serde", @@ -4479,13 +4907,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" [[package]] name = "mesh-mixture-of-agents" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -4535,11 +4963,15 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" dependencies = [ + "aho-corasick", "crossbeam-epoch", "crossbeam-utils", "hashbrown 0.16.1", + "indexmap", "metrics", + "ordered-float 5.3.0", "quanta", + "radix_trie", "rand 0.9.4", "rand_xoshiro", "rapidhash", @@ -4562,6 +4994,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicbor" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0452a60c1863c1f50b5f77cd295e8d2786849f35883f0b9e18e7e6e1b5691b0" + [[package]] name = "minidom" version = "0.16.0" @@ -4571,6 +5009,12 @@ dependencies = [ "rxml", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4595,8 +5039,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "async-trait", @@ -4606,8 +5050,8 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "async-trait", @@ -4624,8 +5068,8 @@ dependencies = [ [[package]] name = "model-package" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "bytes", @@ -4644,16 +5088,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "model-artifact", @@ -4717,9 +5161,9 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "n0-error" -version = "1.0.0-rc.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" dependencies = [ "n0-error-macros", "spez", @@ -4727,13 +5171,13 @@ dependencies = [ [[package]] name = "n0-error-macros" -version = "1.0.0-rc.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4759,9 +5203,9 @@ dependencies = [ [[package]] name = "n0-watcher" -version = "1.0.0-rc.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928d8039a66cce5efcfd35e88b32d3defc8eba630b3ac451522997f563956a52" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" dependencies = [ "derive_more", "n0-error", @@ -4799,19 +5243,22 @@ checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" [[package]] name = "netdev" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" dependencies = [ "block2", "dispatch2", "dlopen2", "ipnet", + "jni 0.21.1", "libc", "mac-addr", + "ndk-context", "netlink-packet-core", - "netlink-packet-route 0.29.0", + "netlink-packet-route", "netlink-sys", + "objc2", "objc2-core-foundation", "objc2-core-wlan", "objc2-foundation", @@ -4832,23 +5279,11 @@ dependencies = [ [[package]] name = "netlink-packet-route" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" dependencies = [ - "bitflags", - "libc", - "log", - "netlink-packet-core", -] - -[[package]] -name = "netlink-packet-route" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092" -dependencies = [ - "bitflags", + "bitflags 2.13.0", "libc", "log", "netlink-packet-core", @@ -4883,14 +5318,15 @@ dependencies = [ [[package]] name = "netwatch" -version = "0.17.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5bfbba77b994ce69f1d40fc66fd8abbd23df62ce4aea61fbb34d638106a2549" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" dependencies = [ "atomic-waker", "bytes", "cfg_aliases", "derive_more", + "ipnet", "js-sys", "libc", "n0-error", @@ -4898,7 +5334,7 @@ dependencies = [ "n0-watcher", "netdev", "netlink-packet-core", - "netlink-packet-route 0.30.0", + "netlink-packet-route", "netlink-proto", "netlink-sys", "noq-udp", @@ -4917,13 +5353,22 @@ dependencies = [ "wmi", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if 1.0.4", "cfg_aliases", "libc", @@ -4936,17 +5381,27 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if 1.0.4", "cfg_aliases", "libc", ] [[package]] -name = "noq" -version = "1.0.0-rc.0" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22739e0831e40f5ab7d6ac5317ed80bfe5fb3f44be57d23fa2eea8bff83fb303" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noq" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" dependencies = [ "bytes", "cfg_aliases", @@ -4966,9 +5421,9 @@ dependencies = [ [[package]] name = "noq-proto" -version = "1.0.0-rc.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cee32450cf726b223ac4154003c93cb52fbde159ab1240990e88945bf3ae35e" +checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" dependencies = [ "aes-gcm", "bytes", @@ -4993,9 +5448,9 @@ dependencies = [ [[package]] name = "noq-udp" -version = "1.0.0-rc.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78633d1fe1bde91d12bcabb230ac9edb890857414c6d44f3212e0d309525b5ff" +checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" dependencies = [ "cfg_aliases", "libc", @@ -5138,6 +5593,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -5208,7 +5674,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5235,7 +5701,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.0", "block2", "dispatch2", "libc", @@ -5248,7 +5714,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" dependencies = [ - "bitflags", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -5268,7 +5734,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -5291,7 +5757,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", ] @@ -5312,7 +5778,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "bitflags", + "bitflags 2.13.0", "dispatch2", "libc", "objc2", @@ -5350,8 +5816,8 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openai-frontend" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "async-trait", "axum", @@ -5371,7 +5837,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if 1.0.4", "foreign-types", "libc", @@ -5387,7 +5853,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5561,6 +6027,24 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -5590,6 +6074,43 @@ dependencies = [ "memchr", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "papaya" version = "0.2.4" @@ -5683,13 +6204,55 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.5", "indexmap", ] @@ -5714,6 +6277,16 @@ dependencies = [ "phf_shared", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -5734,7 +6307,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5763,7 +6336,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5818,7 +6391,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -5873,9 +6446,9 @@ dependencies = [ [[package]] name = "portmapper" -version = "0.17.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aec2a8809e3f7dba624776bb223da9fed49c413c60b3bef21aadcb67a5e35944" +checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" dependencies = [ "base64", "bytes", @@ -5921,7 +6494,7 @@ checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5966,7 +6539,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] @@ -6007,9 +6607,9 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", - "bitflags", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.0", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -6045,7 +6645,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn", + "syn 2.0.117", "tempfile", ] @@ -6059,7 +6659,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6240,7 +6840,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6264,6 +6864,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.6" @@ -6376,13 +6986,114 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.0", + "compact_str 0.9.1", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru 0.18.0", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if 1.0.4", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "raw-cpuid" version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -6431,7 +7142,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -6462,7 +7173,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6550,9 +7261,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -6602,7 +7313,7 @@ dependencies = [ "anyhow", "async-trait", "http", - "reqwest 0.13.3", + "reqwest 0.13.4", "thiserror 2.0.18", "tower-service", ] @@ -6613,6 +7324,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac 0.13.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -6645,7 +7366,7 @@ dependencies = [ "pin-project-lite", "process-wrap", "rand 0.10.1", - "reqwest 0.13.3", + "reqwest 0.13.4", "rmcp-macros", "schemars", "serde", @@ -6670,7 +7391,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn", + "syn 2.0.117", ] [[package]] @@ -6749,7 +7470,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6762,7 +7483,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6815,7 +7536,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -6880,7 +7601,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "826e80413b9a35e9d33217b3dcac04cf95f6559d15944b93887a08be5496c4a4" dependencies = [ - "compact_str", + "compact_str 0.7.1", ] [[package]] @@ -6945,7 +7666,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -6972,6 +7693,20 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -7037,7 +7772,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -7050,7 +7785,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7126,7 +7861,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7137,7 +7872,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7172,7 +7907,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7341,6 +8076,10 @@ name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] [[package]] name = "simd-adler32" @@ -7379,7 +8118,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -7396,8 +8135,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "skippy-cache" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "blake3", @@ -7406,29 +8145,29 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" [[package]] name = "skippy-protocol" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "prost", "prost-build", @@ -7438,8 +8177,8 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "libc", @@ -7452,8 +8191,8 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "async-trait", @@ -7480,8 +8219,8 @@ dependencies = [ [[package]] name = "skippy-topology" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "serde", "serde_json", @@ -7520,7 +8259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7537,7 +8276,7 @@ checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7635,7 +8374,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -7658,7 +8397,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "thiserror 2.0.18", "tokio", "url", @@ -7670,7 +8409,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags", + "bitflags 2.13.0", "byteorder", "bytes", "chrono", @@ -7700,7 +8439,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.13.0", "byteorder", "chrono", "crc", @@ -7825,7 +8564,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7840,6 +8579,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -7868,7 +8618,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7905,7 +8655,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -7959,6 +8709,82 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float 4.6.0", + "pest", + "pest_derive", + "phf", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -7985,7 +8811,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7996,7 +8822,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8101,7 +8927,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8382,7 +9208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", @@ -8449,7 +9275,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8595,7 +9421,17 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "web-time", ] [[package]] @@ -8643,6 +9479,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" @@ -8720,6 +9567,7 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -8781,6 +9629,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -8893,7 +9750,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -8960,7 +9817,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -8993,7 +9850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" dependencies = [ "core-foundation 0.10.1", - "jni", + "jni 0.22.4", "log", "ndk-context", "objc2", @@ -9035,6 +9892,78 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float 4.6.0", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "whoami" version = "2.1.2" @@ -9184,7 +10113,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -9195,7 +10124,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -9277,6 +10206,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -9313,6 +10251,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -9364,6 +10317,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -9376,6 +10335,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -9388,6 +10353,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -9412,6 +10383,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -9424,6 +10401,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -9436,6 +10419,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -9448,6 +10437,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -9514,7 +10509,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -9530,7 +10525,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -9542,7 +10537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.0", "indexmap", "log", "serde", @@ -9587,6 +10582,17 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff", + "group", + "hybrid-array", +] + [[package]] name = "writeable" version = "0.6.3" @@ -9651,7 +10657,7 @@ dependencies = [ "more-asserts", "rand 0.10.1", "redb", - "reqwest 0.13.3", + "reqwest 0.13.4", "reqwest-middleware", "serde", "serde_json", @@ -9764,7 +10770,7 @@ dependencies = [ "oneshot", "pin-project", "rand 0.10.1", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "shellexpand", @@ -9819,7 +10825,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -9864,7 +10870,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -9896,7 +10902,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -9916,28 +10922,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -9970,7 +10976,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -10045,7 +11051,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -10057,5 +11063,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] diff --git a/Cargo.toml b/Cargo.toml index 858774f11b..c729915316 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/buzz-relay", "crates/buzz-core", "crates/buzz-conformance", + "crates/buzz-push-gateway", "crates/buzz-db", "crates/buzz-pubsub", "crates/buzz-auth", @@ -23,6 +24,7 @@ members = [ "crates/git-credential-nostr", "crates/git-sign-nostr", "crates/buzz-pair-relay", + "crates/buzz-relay-mesh", "crates/buzz-dev-mcp", "examples/countdown-bot", ] @@ -45,8 +47,8 @@ tokio-util = { version = "0.7", features = ["rt", "codec"] } # HTTP + WebSocket axum = { version = "0.8", features = ["ws", "macros"] } -tower = { version = "0.5", features = ["timeout", "util"] } -tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit", "fs"] } +tower = { version = "0.5", features = ["timeout", "util", "limit"] } +tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit", "timeout", "fs"] } # Database sqlx = { version = "0.9", features = [ @@ -62,6 +64,10 @@ nostr = { version = "0.44", features = ["nip44", "nip98"] } # Serialization serde = { version = "1", features = ["derive"] } +postcard = { version = "1", default-features = false, features = ["use-std"] } + +# Inter-relay mesh transport (buzz-relay-mesh) +iroh = { version = "1.0.0-rc.0", default-features = false, features = ["tls-ring"] } serde_json = "1" serde_yaml = "0.9" evalexpr = "11" @@ -75,6 +81,7 @@ opentelemetry_sdk = { version = "0.32", features = ["trace", "rt-tokio opentelemetry-otlp = { version = "0.32", default-features = false, features = ["trace", "grpc-tonic", "tls-ring"] } metrics = "0.24" metrics-exporter-prometheus = "0.18" +metrics-util = "0.20" # Error handling thiserror = "2" @@ -127,6 +134,7 @@ buzz-workflow = { path = "crates/buzz-workflow" } buzz-media = { path = "crates/buzz-media" } buzz-sdk = { path = "crates/buzz-sdk" } buzz-ws-client = { path = "crates/buzz-ws-client" } +buzz-relay-mesh = { path = "crates/buzz-relay-mesh" } # CI profile — builds the relay for desktop e2e. Dependencies keep full # release optimization (warm from main's cache; they carry the runtime hot diff --git a/Dockerfile b/Dockerfile index df8f29214c..ddc2579a33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,29 @@ ARG RUST_VERSION=1.95 ARG NODE_VERSION=24 ARG DEBIAN_VERSION=bookworm +# Optional extra CA bundle for builds behind a TLS-intercepting corporate proxy +# (e.g. a Cloudflare/Zscaler gateway that re-signs TLS). Empty by default, so +# public CI builds are unaffected. Point it at a PEM file in the build context: +# docker build --build-arg EXTRA_CA_CERTS=path/to/proxy-ca.pem ... +# Consumed by the network-touching stages below (cargo + pnpm). +ARG EXTRA_CA_CERTS= + +# Optional npm registry for builds where the public registry is unreachable or +# policy-blocked (e.g. a corporate mirror / Artifactory). Empty default = public +# npmjs, so public CI builds are unaffected. Consumed by the web-builder stage. +ARG NPM_REGISTRY= + # ─── Stage 1: cargo-chef base ─────────────────────────────────────────────── FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef +# Trust an optional corporate-proxy CA before any network fetch (no-op if unset). +ARG EXTRA_CA_CERTS +COPY --chmod=0644 ${EXTRA_CA_CERTS:-Dockerfile} /tmp/extra-ca/src +RUN if [ -n "${EXTRA_CA_CERTS}" ]; then \ + cp /tmp/extra-ca/src /usr/local/share/ca-certificates/extra-proxy-ca.crt \ + && update-ca-certificates \ + && echo "CARGO_HTTP_CAINFO=/etc/ssl/certs/ca-certificates.crt" >> /etc/environment; \ + fi +ENV CARGO_HTTP_CAINFO=/etc/ssl/certs/ca-certificates.crt RUN cargo install cargo-chef --locked --version 0.1.71 WORKDIR /build @@ -45,14 +66,41 @@ RUN cargo chef cook --release --recipe-path recipe.json COPY . . RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \ -p buzz-admin --bin buzz-admin \ + -p buzz-pair-relay --bin buzz-pair-relay \ && strip target/release/buzz-relay \ - && strip target/release/buzz-admin + && strip target/release/buzz-admin \ + && strip target/release/buzz-pair-relay # ─── Stage 4: web bundle (pnpm + vite) ────────────────────────────────────── # Independent of the Rust layers so a CSS change doesn't bust Rust cache and # vice versa. FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS web-builder WORKDIR /build +# Trust an optional corporate-proxy CA so corepack + pnpm can fetch over an +# intercepting TLS gateway (no-op if EXTRA_CA_CERTS is unset). +ARG EXTRA_CA_CERTS +COPY --chmod=0644 ${EXTRA_CA_CERTS:-Dockerfile} /tmp/extra-ca/src +RUN if [ -n "${EXTRA_CA_CERTS}" ]; then \ + apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && cp /tmp/extra-ca/src /usr/local/share/ca-certificates/extra-proxy-ca.crt \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/*; \ + fi +ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt +# Point npm + corepack at an optional mirror (no-op when NPM_REGISTRY is unset). +# corepack reads COREPACK_NPM_REGISTRY to fetch the pinned pnpm; pnpm/npm read +# the .npmrc registry for dependency installs. +ARG NPM_REGISTRY +ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY} +# When using a mirror, disable corepack's npmjs signature check: the mirror +# republishes tarballs without the public registry's provenance signatures, so +# strict verification fails ("No compatible signature found"). Only relaxed on +# the mirror path — public builds (NPM_REGISTRY unset) keep strict verification. +RUN if [ -n "${NPM_REGISTRY}" ]; then \ + echo "registry=${NPM_REGISTRY}" > /build/.npmrc \ + && echo "COREPACK_INTEGRITY_KEYS=0" >> /etc/environment; \ + fi +ENV COREPACK_INTEGRITY_KEYS=${NPM_REGISTRY:+0} RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY patches/ patches/ @@ -87,11 +135,14 @@ RUN apt-get update \ COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin +COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay COPY --from=web-builder /build/web/dist /srv/buzz/web +# The invite landing page is always served from the bundled web UI. Repository +# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in. ENV BUZZ_WEB_DIR=/srv/buzz/web -# 3000: app (WS + REST + web UI) · 8080: /_liveness, /_readiness · 9102: /metrics +# 3000: app (WS + REST) · 8080: /_liveness, /_readiness · 9102: /metrics EXPOSE 3000 8080 9102 USER buzz:buzz diff --git a/Dockerfile.push-gateway b/Dockerfile.push-gateway new file mode 100644 index 0000000000..202262d222 --- /dev/null +++ b/Dockerfile.push-gateway @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1.7 +ARG RUST_VERSION=1.95 +ARG DEBIAN_VERSION=bookworm + +FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef +RUN cargo install cargo-chef --locked --version 0.1.71 +WORKDIR /build + +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=planner /build/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json +COPY . . +RUN cargo build --release --locked -p buzz-push-gateway --bin buzz-push-gateway \ + && strip target/release/buzz-push-gateway + +FROM debian:${DEBIAN_VERSION}-slim AS runtime +LABEL org.opencontainers.image.title="Buzz Push Gateway" \ + org.opencontainers.image.description="Capability-gated APNs last hop for Buzz" \ + org.opencontainers.image.source="https://github.com/block/buzz" \ + org.opencontainers.image.licenses="Apache-2.0" +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1000 buzz \ + && useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz --create-home --shell /usr/sbin/nologin buzz +COPY --from=builder /build/target/release/buzz-push-gateway /usr/local/bin/buzz-push-gateway +EXPOSE 8080 8081 +USER buzz:buzz +WORKDIR /var/lib/buzz +ENTRYPOINT ["/usr/local/bin/buzz-push-gateway"] diff --git a/Justfile b/Justfile index d19b74c0c2..bd600d4cd1 100644 --- a/Justfile +++ b/Justfile @@ -43,13 +43,20 @@ bootstrap: setup: bootstrap ./scripts/dev-setup.sh -# Install git hooks via lefthook +# Install git hooks via lefthook (dispatches from the shared .git/hooks dir so all +# linked worktrees inherit the same hooks without a worktree-relative .hooks path) hooks: - git config --local core.hooksPath .hooks + #!/usr/bin/env bash + set -euo pipefail + # --path-format=absolute guarantees an absolute path from every invocation context: + # without it, --git-common-dir returns ".git" from the main checkout and a + # relative hooksPath would break linked-worktree dispatch just like .hooks did. + HOOKS_DIR="$(git rev-parse --path-format=absolute --git-common-dir)/hooks" + git config --local core.hooksPath "$HOOKS_DIR" lefthook install --force -# ⚠️ Wipe ALL data and recreate a clean environment -[confirm("This will DELETE all local data. Continue? (y/N)")] +# Wipe development state and recreate a clean environment. Installed Buzz is preserved. +[confirm("This will DELETE all development data and preserve installed Buzz. Continue? (y/N)")] reset: ./scripts/dev-reset.sh --yes @@ -244,6 +251,9 @@ test-unit: # replay — so it belongs in the unit job. Run all targets (lib + the # tests/replay_fixtures.rs integration test), not just --lib. cargo nextest run -p buzz-conformance + # Gateway unit and black-box HTTP tests are infra-free. Postgres-backed + # contract/race tests run in the dedicated CI job below. + cargo nextest run -p buzz-push-gateway else ./scripts/run-tests.sh unit fi @@ -252,18 +262,55 @@ test-unit: test-integration: ./scripts/run-tests.sh integration -# Mesh-compute e2e: the CI-safe layers (relay mesh signaling invariants + Playwright UI) +# Buzz shared compute e2e: current desktop discovery/admission logic and +# Playwright UI coverage. mesh-e2e: - cargo test -p buzz-relay mesh_signaling - cd {{desktop_dir}} && pnpm test:e2e:integration -- mesh-compute.spec.ts + cargo test --manifest-path {{desktop_dir}}/src-tauri/Cargo.toml --features mesh-llm mesh_llm --lib + cd {{desktop_dir}} && pnpm test:e2e:smoke -- mesh-compute.spec.ts -# Mesh-compute Layer 1: REAL serve->client->inference on this machine (not CI) +# Reset only development state, seed deterministic local channels, and launch +# the mesh-enabled desktop with the repository's public Tyler test identity. +# This is for local verification only; never point this identity at staging/prod. +[confirm("This will reset development data, preserve installed Buzz, then launch a seeded mesh dev app. Continue? (y/N)")] +mesh-dev-fresh: + #!/usr/bin/env bash + set -euo pipefail + ./scripts/dev-reset.sh --yes + ./scripts/setup-desktop-test-data.sh + export BUZZ_PRIVATE_KEY="3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03" + export BUZZ_REQUIRE_RELAY_MEMBERSHIP=true + export BUZZ_ALLOW_NIP_OA_AUTH=true + export RELAY_OWNER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34" + export BUZZ_RELAY_PRIVATE_KEY="0000000000000000000000000000000000000000000000000000000000000001" + export BUZZ_RECONCILE_CHANNELS=true + export BUZZ_RESET_WEBVIEW_STATE=1 + exec just mesh=1 dev + +# Real serve->client->inference on this machine (not CI). mesh-e2e-hardware: #!/usr/bin/env bash set -euo pipefail export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo run -p buzz-relay --example mesh_serve_client_smoke +# Three isolated node processes: trusted member joins and infers; stranger is rejected. +# Uses temp homes and explicit mesh owner keystores. Never reads the Buzz Keychain. +mesh-e2e-admission: + #!/usr/bin/env bash + set -euo pipefail + export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" + cargo run -p buzz-relay --example mesh_admission_smoke + +# Full hardware confidence suite: routing, owner admission, and real agent inference. +mesh-e2e-confidence: + #!/usr/bin/env bash + set -euo pipefail + export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" + cargo build --release -p buzz-agent -p buzz-dev-mcp + cargo run -p buzz-relay --example mesh_serve_client_smoke + cargo run -p buzz-relay --example mesh_admission_smoke + cargo run -p buzz-relay --example mesh_agent_e2e + # Take desktop screenshots using the mock bridge desktop-screenshot *ARGS: #!/usr/bin/env bash @@ -321,19 +368,38 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations done fi cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay + if [[ -n "{{mesh}}" ]]; then + export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" + fi + # Docker Desktop's forwarded MinIO port can stall under the deployment + # probe's 32 concurrent writers. Keep the gate enabled in local dev, using + # the bounded profile already used by the relay test launcher. + export BUZZ_GIT_PROBE_WRITERS="${BUZZ_GIT_PROBE_WRITERS:-8}" + export BUZZ_GIT_PROBE_ROUNDS="${BUZZ_GIT_PROBE_ROUNDS:-2}" ./target/debug/buzz-relay & RELAY_PID=$! - sleep 1 - if ! kill -0 "$RELAY_PID" 2>/dev/null; then - echo "Error: buzz-relay exited during startup; refusing to launch desktop against a stale relay." >&2 - wait "$RELAY_PID" || true - exit 1 - fi cleanup() { [[ -n "${INSTANCE_ID:-}" ]] && ../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true kill "$RELAY_PID" 2>/dev/null || true } trap cleanup EXIT + relay_ready=false + for _ in $(seq 1 120); do + if ! kill -0 "$RELAY_PID" 2>/dev/null; then + echo "Error: buzz-relay exited during startup; refusing to launch desktop." >&2 + wait "$RELAY_PID" || true + exit 1 + fi + if curl --silent --fail --max-time 1 "http://127.0.0.1:${health_port}/_readiness" >/dev/null; then + relay_ready=true + break + fi + sleep 0.5 + done + if [[ "$relay_ready" != true ]]; then + echo "Error: buzz-relay did not become healthy within 60 seconds; refusing to launch desktop." >&2 + exit 1 + fi cd {{desktop_dir}} [[ -d node_modules ]] || pnpm install source ../scripts/instance-env.sh @@ -356,7 +422,8 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs fi # Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up. TARGET=$(rustc -vV | sed -n 's|host: ||p') - cp target/release/buzz "desktop/src-tauri/binaries/buzz-${TARGET}" + TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") + cp "${TARGET_DIR}/release/buzz" "desktop/src-tauri/binaries/buzz-${TARGET}" chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}" cd {{desktop_dir}} export BUZZ_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co" @@ -436,6 +503,10 @@ mobile-check: mobile-test: unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test +# Compile an unsigned Android debug APK +mobile-build-android: + unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub + # Run the mobile app on iOS simulator mobile-dev: #!/usr/bin/env bash @@ -601,6 +672,7 @@ _release-pr lane version: TAG_PREFIX="v" CHANGELOG="CHANGELOG.md" ADD_FILES=(desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.lock pnpm-lock.yaml CHANGELOG.md) + LOG_PATHS=(desktop/ crates/buzz-core/ crates/buzz-persona/ crates/buzz-sdk/ crates/buzz-agent/) ARTIFACT="Buzz Desktop" ;; relay) BRANCH_PREFIX="relay-release" @@ -610,6 +682,7 @@ _release-pr lane version: TAG_PREFIX="relay-v" CHANGELOG="crates/buzz-relay/CHANGELOG.md" ADD_FILES=(crates/buzz-relay/Cargo.toml Cargo.lock crates/buzz-relay/CHANGELOG.md) + LOG_PATHS=(crates/buzz-relay/ crates/buzz-core/ crates/buzz-db/ crates/buzz-auth/ crates/buzz-pubsub/ crates/buzz-search/ crates/buzz-audit/ crates/buzz-media/ crates/buzz-sdk/ crates/buzz-workflow/ crates/buzz-conformance/ migrations/) ARTIFACT="Buzz Relay" ;; mobile) BRANCH_PREFIX="mobile-release" @@ -619,6 +692,7 @@ _release-pr lane version: TAG_PREFIX="mobile-v" CHANGELOG="mobile/CHANGELOG.md" ADD_FILES=(mobile/pubspec.yaml mobile/pubspec.lock mobile/CHANGELOG.md) + LOG_PATHS=(mobile/) ARTIFACT="Buzz Mobile" ;; *) echo "Error: unknown release lane '{{ lane }}'" @@ -667,7 +741,7 @@ _release-pr lane version: REPO=$(git remote get-url origin | sed -E 's|.*github\.com[:/]||; s|\.git$||') format_log() { local range="$1" - git log "$range" --format="%h %H %s" --no-merges | while IFS=' ' read -r short full rest; do + git log "$range" --format="%h %H %s" --no-merges -- "${LOG_PATHS[@]}" | while IFS=' ' read -r short full rest; do local pr subject pr=$(printf '%s' "$rest" | grep -oE '\(#[0-9]+\)$' | grep -oE '[0-9]+' || true) if [[ -n "$pr" ]]; then @@ -711,7 +785,17 @@ _release-pr lane version: PR_BODY="## ${ARTIFACT} release v${VERSION}"$'\n\n' if [[ -n "$LAST_TAG" ]]; then PR_BODY+="### Changes since ${LAST_TAG}:"$'\n\n' - PR_BODY+="$(format_log "${LAST_TAG}..HEAD~1")"$'\n\n' + CHANGELOG_BODY=$(format_log "${LAST_TAG}..HEAD~1") + MAX_LOG=62000 + if (( ${#CHANGELOG_BODY} > MAX_LOG )); then + TRUNCATED=$(printf '%s' "$CHANGELOG_BODY" | awk -v max="$MAX_LOG" \ + 'BEGIN{n=0} {line_len=length($0)+1; if(n+line_len>max) exit; n+=line_len; print}') + SHOWN=$(printf '%s\n' "$TRUNCATED" | grep -c '^-' || true) + TOTAL=$(printf '%s\n' "$CHANGELOG_BODY" | grep -c '^-' || true) + SKIPPED=$(( TOTAL - SHOWN )) + CHANGELOG_BODY="${TRUNCATED}"$'\n'"_… and ${SKIPPED} more commits — [compare ${LAST_TAG}…${TAG_PREFIX}${VERSION}](https://github.com/${REPO}/compare/${LAST_TAG}...${TAG_PREFIX}${VERSION})_" + fi + PR_BODY+="${CHANGELOG_BODY}"$'\n\n' else PR_BODY+="Initial release."$'\n\n' fi @@ -748,3 +832,17 @@ goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$BU source ./scripts/_goose-env.sh "{{relay}}" "{{key}}" "{{agents}}" "{{heartbeat}}" "{{prompt}}" screen -dmS goose-agent-{{agents}} bash -c "$(printf '%q ' env "${env_args[@]}") ./target/release/buzz-acp" echo "Agent running in screen session 'goose-agent-{{agents}}'. Attach with: screen -r goose-agent-{{agents}}" + +# ─── Benchmarking ───────────────────────────────────────────────────────────── + +# Run the Buzz orchestra benchmark — leaderboard-eligible by default (TB 2.1, k=5, Sonnet+Haiku). Stands up its own Docker stack; --gui opens a live spectator desktop app; other flags pass to benchmark.py (--dataset/--path, --include-task, --attempts, --manifest, --dry-run, ...) +benchmark *ARGS: + #!/usr/bin/env bash + set -euo pipefail + export PATH="{{justfile_directory()}}/bin:$PATH" + uv run --project benchmarks/harbor-buzz-orchestra/testbed \ + benchmarks/harbor-buzz-orchestra/scripts/benchmark.py {{ARGS}} + +# Stop the benchmark Docker stack (state and channels are kept) +benchmark-down: + docker compose --project-name buzz-benchmark down diff --git a/README.md b/README.md index 9d6a75b4d6..c6a6388dbb 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,28 @@ Agents are part of the room, not haunted cron jobs. --- +## Getting started + +New to Buzz? Pick the path that matches you. + +### I just want to try the app + +Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest) — macOS (`.dmg`), Linux (`.AppImage` / `.deb`), or Windows (`.exe`). Install it like any other app. + +By default the app connects to `ws://localhost:3000`. To point it at a relay you're running or one someone shared with you, set `BUZZ_RELAY_URL` before launching, or switch the relay from inside the app. If you don't have a relay yet, follow **Build & run from source** below to stand one up locally. + +### I work at Block + +Don't build from source, and don't use the OSS release — use the internal build. It comes pre-wired to the Block relay and agent provider, so it works out of the box with nothing to configure. + +Download the latest build from [`squareup/buzz-releases` releases](https://github.com/squareup/buzz-releases/releases/latest) and install it. + +### I want to build & run from source + +See **Quick start** below — this is the developer / self-host path. + +--- + ## Quick start You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.88+, Node 24+, pnpm 10+, `just`). @@ -124,6 +146,7 @@ just setup && just build **Every day:** ```bash +. ./bin/activate-hermit just dev # starts the relay + desktop app together ``` diff --git a/RELEASING.md b/RELEASING.md index ea0bd897b9..98d5dd9c80 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -174,8 +174,9 @@ Each release produces two GitHub releases: (macOS). 2. **`buzz-desktop-latest`** — a rolling pre-release for the Tauri - auto-updater containing `latest.json`, the signed `.tar.gz` archive, - and its `.sig` signature. + auto-updater containing `latest.json` and each platform's signed + updater artifact plus its `.sig` signature (`.tar.gz` on macOS, + `.AppImage` on Linux, and `_alpha-unsigned.exe` on Windows). --- @@ -187,6 +188,15 @@ Silicon (`darwin-aarch64`, the `release` job) and Intel `.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to the same `v` release. Intel users download the `_x64.dmg`. +The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, +which strips infra libraries over-bundled by linuxdeploy (they crash on +Mesa 25+ / GLib 2.88 distros — see +[tauri-apps/tauri#15665](https://github.com/tauri-apps/tauri/issues/15665)) +and re-signs the artifact. As a result the AppImage relies on the +host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 +(Ubuntu 22.04 or newer). The `release-linux` job builds inside a +`ubuntu:22.04` container for broad GLIBC compatibility. + --- ## Prerequisites @@ -219,7 +229,7 @@ The version string must be valid semver: `MAJOR.MINOR.PATCH` with an optional pr ### Auto-updater reports "no update available" Verify that the `buzz-desktop-latest` release exists and contains a -valid `latest.json`. The auto-updater manifest currently lists -`darwin-aarch64` only, so Intel and Linux users do not yet receive -auto-updates — they download new versions manually from the release -page. (Adding their entries to `latest.json` is a follow-up.) +valid `latest.json`. The manifest covers all four platform keys +(`darwin-aarch64`, `darwin-x86_64`, `linux-x86_64`, +`windows-x86_64`); a missing entry usually means that platform's +release job failed — check the workflow run. diff --git a/TESTING.md b/TESTING.md index a1ddb35ecd..ad5225244a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -269,9 +269,12 @@ out of the box with `just setup` or `just relay`. Common overrides: | `REDIS_URL` | `redis://localhost:6379` | | | `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) | | `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect | +| `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. | | `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup | | `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start | | `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership | +| `BUZZ_WEB_DIR` | unset (source), `/srv/buzz/web` (container) | Directory containing the invite landing bundle; the production container enables it so `/invite/{code}` always works | +| `BUZZ_SERVE_GIT_WEB_GUI` | `false` | Set to `true` or `1` to expose the bundled Git repository browser at `/` and `/repos/...`; invite routes do not depend on this flag | CLI-side, only two matter for testing: diff --git a/VISION.md b/VISION.md index 9093b75ddc..b09f661ee3 100644 --- a/VISION.md +++ b/VISION.md @@ -143,6 +143,7 @@ Beyond chat: channels are workspaces. - **Canvases** — a shared document per channel. Read and write via the desktop or MCP tools. - **Media uploads** — paste, drop, or attach files. Stored via the [Blossom](https://github.com/hzrd149/blossom) protocol (BUD-01/BUD-02) on S3/MinIO. Thumbnails generated server-side. - **Message editing and deletion** — with confirmation. Soft-deleted events remain in the audit log. +- **Community moderation** — private reports, owner/admin queues, structural enforcement, audit, and best-effort notices. See [VISION_MODERATION.md](VISION_MODERATION.md) for the full governance model. - **Typing indicators** — real-time. Agents broadcast them too. --- @@ -165,7 +166,7 @@ See [VISION_PROJECTS.md](VISION_PROJECTS.md) for the full forge vision: the proj ## Agent Personas & Teams -Agents aren't monolithic. A persona bundles a model, a system prompt, and a set of MCP toolsets. A team is a named group of personas — deploy Ralph for code review, Scout for research, Reviewer for crossfire. Built-in personas ship with the desktop client; operators define their own. +Agents aren't monolithic. A persona bundles a model and a system prompt. A team is a named group of personas — deploy Ralph for code review, Scout for research, Reviewer for crossfire. Built-in personas ship with the desktop client; operators define their own. --- diff --git a/VISION_MODERATION.md b/VISION_MODERATION.md new file mode 100644 index 0000000000..45d3ab86fd --- /dev/null +++ b/VISION_MODERATION.md @@ -0,0 +1,71 @@ +# 🛡️ Buzz Moderation — Your community, your rules + +> Someone spams #general at midnight. A member taps **Report** — a category, an optional note, done. The report doesn't appear in anyone's feed; it lands in a queue only the community's owners and admins can see. In the morning an admin opens the queue, finds three reports against the same account, deletes the messages, and times the account out for a day. The room sees an honest marker where the spam was. The author gets a message explaining why. The reporter gets a message saying it was handled. Nobody else saw anything. + +A Buzz community is a trust group with its own rules, and rules only matter if the people who own the room can enforce them. Buzz moderation gives community owners and admins the full loop: members report, the community's own owners and admins see and act, the relay enforces, and everyone affected hears the truth about what happened. The relay provides the mechanics — queue, authority, enforcement, audit, notices. The community provides the judgment. Buzz doesn't decide what your rules are; it makes sure you can actually have them. + +Most of the nostr ecosystem treats moderation as **admission policy** — allow lists, block lists, a hook that rejects an event at the door. Buzz treats it as **workflow**: a report is the start of a human decision, not a trigger for an automatic one. That difference is the whole design. + +--- + +## Two Layers, Not One + +Moderation splits the way it does on every serious platform: + +**Community moderation** — subjective, per-community rule enforcement. Your owners and admins decide what's spam in *your* community, what crosses *your* line, who gets a second chance. This layer belongs to the community and never reaches past it: an admin's authority ends at the community boundary, structurally, because every moderation decision is scoped to the tenant it was made in. + +**Platform safety** — the severe class: illegal content, network-level abuse, legal reporting obligations. That is never delegated to community admins. A community owner or admin can **escalate** a report upward, and the escalation is recorded durably for the platform operator's safety process. The community layer is the front line; the platform layer is the backstop. + +This document is about the first layer. The second has its own lane. + +--- + +## What You See + +**As a member**, every message has a Report action. Pick why — spam, profanity, illegal content, impersonation, or another supported reason — add context if you want, send. Your report is private: it is never broadcast, never stored as a public event, never visible to the person you reported. It goes to the people who can act on it, and only them. + +**As an owner or admin**, you have a queue. Reports arrive grouped by target, newest first, with the reporter's identity visible to you — accountability runs both ways — and never to the reported author. From the queue you act in one motion: dismiss, delete the message, kick, timeout, ban, or escalate. Reasons travel where they should — to the audit trail, to the tombstone, to the restricted user — without exposing private report context to the room. + +**As the room**, a removed message leaves an honest tombstone — "removed by a community moderator," with a sanitized reason — instead of a silent hole. The room learns that the rules are real without republishing the offense. + +**As someone restricted**, you hear it straight: a message from the community's moderation identity telling you what restriction was applied, why, and for how long. A timeout disables your composer with a visible countdown — you can read, you can't post, and you know exactly when that ends. No silent write-drops, no shadow bans, no guessing. + +**As the reporter**, you hear the outcome. The loop closes. Reporting doesn't feel like shouting into a void — which is the difference between a community that self-polices and one that gives up. + +--- + +## The Mechanics That Matter + +- **Reports are signals, never triggers.** No user report auto-removes anything. Reports are gameable; human judgment is the gate. The queue aggregates and an owner or admin decides. + +- **Reports are private structural state.** A report is validated and filed — never stored in the event log, never fanned out to subscribers. Reporter identity can't leak through a future query bug, because it was never in the public store to begin with. + +- **Moderation actions are signed commands.** A community owner's or admin's ban, timeout, or report resolution is a cryptographically signed event, validated against their actual role and executed — never stored as content. Authority comes from the community's own roster — owners and admins — with guard rails built in: an admin cannot ban or time out an owner or another admin. + +- **Enforcement lives at the identity seam.** A ban bites when the banned key tries to authenticate — rejected at the door, disconnected everywhere, immediately. A timeout is a write-block with a stated expiry. Enforcement isn't scattered through the codebase as filters; it happens where identity is established, which is why it can't be sidestepped. + +- **The important decisions are audited.** Bans, timeouts, report dismissals, escalations, and report resolutions write durable audit rows — who, what, whom, why, when — with the decision recorded separately from its enforcement, so the trail never claims something happened that didn't. Message removals also leave visible tombstones for the room. The full report record (including reporter identity and notes) stays moderator-only; the public sees only the sanitized reason. + +- **The wire uses nostr where nostr has the right primitive.** Reports are NIP-56. Group roles and membership actions are NIP-29. Buzz's moderation commands and private reads fill the workflow gaps those NIPs deliberately leave open. + +--- + +## Honest Edges + +**Escalation is a hook today, not a pipeline.** Escalating writes a durable, queryable record for the platform operator — but the platform-side inbox that consumes it is a separate build. The substrate is there; the tooling above it comes next. + +**Two roles, not three.** Owners and admins moderate. There is no volunteer-moderator tier yet — deliberately. Authority is structured as capabilities, so adding a moderator tier later is a policy change, not a rewrite. We'd rather ship a loop that works and grow the org chart when communities ask for it. + +**Notices are best-effort.** The DMs that close the loop never block enforcement — a ban lands even if the notice fails. Enforcement is the promise; notification is the courtesy. A later platform-escalation pass should also make escalated reports say exactly that, instead of reusing the generic handled message. + +**No automod.** Nothing scans content before it posts. Pre-send filtering, trusted-reporter weighting, and shared blocklists are future layers on top of this substrate — the report/decide/enforce/audit loop is the part that had to be right first. + +--- + +## The Point + +A community you can't moderate isn't yours — it just has your name on it. The relay is the workspace; moderation is what makes it *governable* by the people it belongs to. Judgment stays human. Enforcement is structural. Everyone affected hears the truth. + +--- + +*Buzz 🐝 — your community, your rules.* diff --git a/benchmarks/harbor-buzz-orchestra/.gitignore b/benchmarks/harbor-buzz-orchestra/.gitignore new file mode 100644 index 0000000000..356ac54922 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.benchmark/ +jobs/ diff --git a/benchmarks/harbor-buzz-orchestra/README.md b/benchmarks/harbor-buzz-orchestra/README.md new file mode 100644 index 0000000000..0358c954e7 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/README.md @@ -0,0 +1,129 @@ +# Harbor Buzz Orchestra + +A stock-Harbor custom agent that runs a manifest-defined team through the real +Buzz stack. Harbor sees one `BuzzOrchestraAgent`; behind that adapter, one +orchestrator and N workers coordinate over the production relay/Postgres. +Each agent runs *inside* the Harbor task container as the same +`buzz-acp` → `buzz-agent` → `buzz-dev-mcp` process tree the desktop app +launches: the production MCP toolset (shell, file tools, todo) with the +`buzz` CLI on the shell's PATH. No Harbor fork or patch is required. + +## Define the team + +The manifest is the benchmark condition. Each roster entry selects an agent +class's count, model endpoint, byte-pinned system prompt, generation settings, +and budget: + +```yaml +condition: my-team +roster: + - id: orch + kind: orchestrator + role: lead + count: 1 + endpoint: databricks/frontier + prompt: {path: personas/orchestrator.md, sha256: } + generation: {max_output_tokens: 4096, context_window_tokens: 128000} + - id: worker + kind: worker + role: implementer + count: 4 + endpoint: databricks/fast-worker + prompt: {path: personas/worker.md, sha256: } + generation: {max_output_tokens: 4096, context_window_tokens: 128000} +``` + +`endpoint_config` maps those endpoint names to providers, URLs, and API-key +environment variables. The adapter contains no fixed roster or model. + +## Run + +With the production compose stack and model endpoints already running, execute +one task (`-p`), a directory of tasks, or replace `-p` with Harbor's dataset and +task selectors: + +```bash +uv run --project benchmarks/harbor-buzz-orchestra/testbed harbor run --yes -p --agent harbor_buzz_orchestra:BuzzOrchestraAgent --agent-kwarg manifest= --agent-kwarg provisioner_factory=harbor_buzz_testbed:provisioner_from_dict --agent-kwarg provisioner_config= --agent-kwarg endpoint_config= --agent-kwarg artifact_root=benchmarks/harbor-buzz-orchestra --agent-kwarg buzz_acp_binary=/buzz-acp --agent-kwarg buzz_agent_binary=/buzz-agent --agent-kwarg buzz_dev_mcp_binary=/buzz-dev-mcp --agent-kwarg buzz_cli_binary=target/debug/buzz --agent-kwarg run_id="bench-$(date -u +%Y%m%dT%H%M%SZ)" --agent-timeout-multiplier 15 --n-concurrent 1 +``` + +`buzz_acp_binary`/`buzz_agent_binary`/`buzz_dev_mcp_binary` must be **Linux** +builds matching the task image architecture — they are uploaded into each task +container (`just benchmark` cross-builds them automatically; musl-static, so +any Linux base image works). `buzz_cli_binary` is the **host** CLI the harness +uses to act as the trial user. + +`--n-concurrent 1` is the safe laptop setting for a serialized local model; it +is not an orchestration requirement. Some TB graders install dependencies from +public package registries at verification time — run benchmarks off networks +that block those installs (e.g. corporate VPNs). + +Each trial gets fresh keys and a private Buzz channel. The provisioner archives +rather than deletes that channel, leaving the relay/Postgres event timeline +and the per-agent acp/agent logs (downloaded into the trial's `buzz/` +artifacts) available for analysis. + +## Leaderboard runs + +`just benchmark` is the one-command path: it stands up a dedicated Docker +stack (`buzz-benchmark` compose project — relay :3600, Postgres :5633, secrets +generated once into the gitignored `.benchmark/`), applies the benchmark +schema, and defaults to leaderboard-eligible settings (Terminal-Bench 2.1, +5 attempts per problem, the Sonnet+Haiku team). All selectors pass through: + +```bash +just benchmark # full TB 2.1, k=5 +just benchmark --path -k 1 # one local task, one attempt +just benchmark -i "cobol*" --attempts 3 # dataset subset +just benchmark --gui # watch the run live +``` + +One pinned user identity fronts the whole benchmark environment: it owns +every trial channel (named after the task) and posts every task prompt, and +trial channels are kept rather than archived. `--gui` adds that user to the +relay membership list and opens the Buzz desktop app logged in as them, so +channels fill the sidebar as the run progresses — watch, don't type; a human +message mid-trial would taint the run. `just benchmark-down` stops the stack. + +Networking: the relay is host-header tenant-bound, so agents must dial its +canonical address (`ws://localhost:3600`) even from inside a task container. +`just benchmark` uploads a tiny std-only loopback forwarder +([`forwarder/relay_forwarder.rs`](forwarder/relay_forwarder.rs)) with the +agent stack; it listens on the container's loopback and bridges the byte +stream to the Docker host gateway (`host.docker.internal`, overridable via +`BUZZ_BENCHMARK_DOCKER_HOST`). + +`scripts/run_leaderboard.py` is the layer underneath, for running against an +already-provisioned stack. It wraps the invocation above with only +leaderboard-legal settings — it does not accept or forward timeout or resource +overrides, so the job directory it produces passes Harbor's static validation +as-is. Give it a problem set, attempts per problem, and a team manifest: + +```bash +uv run --project benchmarks/harbor-buzz-orchestra/testbed \ + benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py \ + --dataset terminal-bench/terminal-bench-2-1 \ + --attempts 5 \ + --manifest benchmarks/harbor-buzz-orchestra/manifests/.yaml \ + --endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/.json \ + --provisioner-config +``` + +`--path` replaces `--dataset` for local task directories; `--include-task` / +`--exclude-task` filter by glob; `--dry-run` prints the underlying `harbor run` +command. After the job finishes the script derives a `metadata.yaml` from the +manifest roster (validated schema; review the display names before submitting) +and prints the `harbor upload` / `harbor leaderboard submit` commands. + +## Validate + +```bash +cd benchmarks/harbor-buzz-orchestra +uv run --extra dev pytest -q +uv run --extra dev ruff check . +cd testbed +uv run --extra dev pytest -q +uv run --extra dev ruff check . +``` + +Live provisioner tests require the benchmark compose stack and opt-in +environment described in `testbed/tests/test_provisioner_live.py`. diff --git a/benchmarks/harbor-buzz-orchestra/forwarder/relay_forwarder.rs b/benchmarks/harbor-buzz-orchestra/forwarder/relay_forwarder.rs new file mode 100644 index 0000000000..c6aef9a2a5 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/forwarder/relay_forwarder.rs @@ -0,0 +1,73 @@ +//! Loopback TCP forwarder for the benchmark task container. +//! +//! The benchmark relay is host-header tenant-bound: its community row is the +//! authority of its own `RELAY_URL` (e.g. `localhost:3600`), and a request +//! presenting any other `Host` fails closed. Agents inside a Harbor task +//! container can only reach the host-published relay via the Docker host +//! alias (`host.docker.internal`), which would present the wrong `Host`. +//! +//! So the container runtime uploads this forwarder next to the agent stack: +//! agents dial `ws://localhost:` — presenting the exact `Host` the +//! community row expects — and the forwarder bridges the byte stream to the +//! host gateway. Transparent to everything above TCP (WebSocket, the buzz +//! CLI, git-over-HTTP). std-only; compiled with plain `rustc` against the +//! musl target, so it runs on any Linux task image. +//! +//! Usage: `relay-forwarder ` + +use std::io::{self, Read, Write}; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::thread; + +fn main() -> io::Result<()> { + let mut args = std::env::args().skip(1); + let (listen, target) = match (args.next(), args.next()) { + (Some(listen), Some(target)) => (listen, target), + _ => { + eprintln!("usage: relay-forwarder "); + std::process::exit(2); + } + }; + let listener = TcpListener::bind(&listen)?; + // Readiness marker: the container runtime polls the log for this line + // before launching any agent. + println!("forwarding {listen} -> {target}"); + for client in listener.incoming() { + let Ok(client) = client else { continue }; + let target = target.clone(); + thread::spawn(move || bridge(client, &target)); + } + Ok(()) +} + +/// Connect upstream and pump bytes both ways until either side closes. +fn bridge(client: TcpStream, target: &str) { + let Ok(upstream) = TcpStream::connect(target) else { + let _ = client.shutdown(Shutdown::Both); + return; + }; + let (Ok(client_read), Ok(upstream_read)) = (client.try_clone(), upstream.try_clone()) + else { + return; + }; + let downstream = thread::spawn(move || pipe(upstream_read, client)); + pipe(client_read, upstream); + let _ = downstream.join(); +} + +/// Copy until EOF or error, then half-close the write side so protocols +/// layered on TCP (WebSocket close handshakes) terminate cleanly. +fn pipe(mut from: TcpStream, mut to: TcpStream) { + let mut buf = [0u8; 16 * 1024]; + loop { + match from.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if to.write_all(&buf[..n]).is_err() { + break; + } + } + } + } + let _ = to.shutdown(Shutdown::Write); +} diff --git a/benchmarks/harbor-buzz-orchestra/manifests/m1-hello-world.yaml b/benchmarks/harbor-buzz-orchestra/manifests/m1-hello-world.yaml new file mode 100644 index 0000000000..b972e5e3f6 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/manifests/m1-hello-world.yaml @@ -0,0 +1,44 @@ +# M1 gate manifest: 1 orchestrator + 1 worker over one Buzz channel solving +# Harbor's examples/tasks/hello-world via vanilla `harbor run`. +# +# Endpoints are LOCAL PLACEHOLDERS for the M1 wiring proof; the pilot/2.1 +# manifests will carry exact Databricks serving endpoint revisions and the +# frozen price table per the canonical plan. +schema_version: "1" +condition: M1-hello-world +roster: + - id: orch + kind: orchestrator + role: lead + count: 1 + endpoint: local/placeholder-orchestrator + model_revision: m1-placeholder + prompt: + path: personas/orchestrator-m1.md + sha256: af64b515de914c99fbdfc5a50830909ac10ab3a4f37e8352a354019a6be74151 + generation: + max_output_tokens: 4096 + context_window_tokens: 128000 + - id: worker + kind: worker + role: implementer + count: 1 + endpoint: local/placeholder-worker + model_revision: m1-placeholder + prompt: + path: personas/worker-m1.md + sha256: f3fef3d2c42105c256ff019d71ef99506b31a23e8cb252cac727c8bc470eb304 + generation: + max_output_tokens: 4096 + context_window_tokens: 128000 +prices: + local/placeholder-orchestrator: + input_per_million_usd: 0 + cached_input_per_million_usd: 0 + output_per_million_usd: 0 + local/placeholder-worker: + input_per_million_usd: 0 + cached_input_per_million_usd: 0 + output_per_million_usd: 0 +trial_budget: + timeout_seconds: 1800 diff --git a/benchmarks/harbor-buzz-orchestra/manifests/tb-cobol-sonnet-haiku.yaml b/benchmarks/harbor-buzz-orchestra/manifests/tb-cobol-sonnet-haiku.yaml new file mode 100644 index 0000000000..19289f1ca3 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/manifests/tb-cobol-sonnet-haiku.yaml @@ -0,0 +1,42 @@ +# Terminal-Bench trial: 1 Sonnet 4.6 orchestrator + 2 Haiku 4.5 workers over +# one Buzz channel. Endpoint names are exact Anthropic model IDs (the runtime +# passes the endpoint name to the provider as the model). +# Prices: Anthropic list $/Mtok as of 2026-07 (Sonnet 3/15, Haiku 1/5). +schema_version: "1" +condition: tb-cobol-sonnet46-2haiku45 +roster: + - id: orch + kind: orchestrator + role: lead + count: 1 + endpoint: claude-sonnet-4-6 + model_revision: claude-sonnet-4-6 + prompt: + path: personas/orchestrator-tb.md + sha256: 206829331cdd85fb277266901b9489e190ba23097600a8bb69142d044b9a42e6 + generation: + max_output_tokens: 4096 + context_window_tokens: 200000 + - id: worker + kind: worker + role: implementer + count: 2 + endpoint: claude-haiku-4-5 + model_revision: claude-haiku-4-5 + prompt: + path: personas/worker-tb.md + sha256: 664a994ae3ba93f02ebbb8f61f781f6a59ea3327b21e26e7ffc2d06827ed3e45 + generation: + max_output_tokens: 4096 + context_window_tokens: 200000 +prices: + claude-sonnet-4-6: + input_per_million_usd: 3 + cached_input_per_million_usd: 0.3 + output_per_million_usd: 15 + claude-haiku-4-5: + input_per_million_usd: 1 + cached_input_per_million_usd: 0.1 + output_per_million_usd: 5 +trial_budget: + timeout_seconds: 900 diff --git a/benchmarks/harbor-buzz-orchestra/personas/orchestrator-m1.md b/benchmarks/harbor-buzz-orchestra/personas/orchestrator-m1.md new file mode 100644 index 0000000000..d248806e4e --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/personas/orchestrator-m1.md @@ -0,0 +1,34 @@ +# Orchestrator — M1 hello-world + +You are the orchestrator of a small team solving a terminal task. You do not +run commands yourself; workers do. You coordinate over a Buzz channel. + +Your `shell` tool has the `buzz` CLI on PATH, already authenticated as +you. Nothing you write is visible to anyone unless you publish it: every +message — step assignments, verification requests, the final `DONE:` +— must be sent with +`buzz messages send --channel --content `. Your team, your +channel id, and the user you report to are listed in the "Your team" +section below. Your turn is not complete until you have published your +message. + +Rules: +1. Read the task instruction. Break it into the smallest concrete steps. +2. Assign each step to a worker with an @mention. One step per message. + State the exact goal and the success check, not just the command to run. + The task runs in the worker's terminal working directory. Unless the task + instruction itself names a path, refer to files by bare relative name + (`hello.txt`) — never invent an absolute path. More generally, relay the + task's requirements verbatim and do not add constraints the task does not + state (paths, encodings, byte-level rules such as forbidding a trailing + newline). Where the task is silent, let standard tool defaults apply. +3. Wait for the worker's report before assigning the next dependent step. +4. When a worker reports output, verify it against the task's success + criteria yourself before moving on. +5. When the task is complete, report back to the user: publish a final + message starting with `DONE:` that @mentions the user and summarizes + what was produced and how you verified it. The task is not finished + until this message is published — never conclude silently. + +Keep messages short. Never fabricate command output. If a worker's report is +ambiguous, ask them to re-run with the exact verification command. diff --git a/benchmarks/harbor-buzz-orchestra/personas/orchestrator-tb.md b/benchmarks/harbor-buzz-orchestra/personas/orchestrator-tb.md new file mode 100644 index 0000000000..c1dbd7b795 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/personas/orchestrator-tb.md @@ -0,0 +1,43 @@ +# Orchestrator — Terminal-Bench team + +You are the orchestrator of a small team solving a terminal task. You do not +run commands yourself; your workers do. You coordinate over a Buzz channel. +Your team, your channel id, and the user you report to are listed in the +"Your team" section below. + +Your `shell` tool has the `buzz` CLI on PATH, already authenticated as +you. Nothing you write is visible to anyone unless you publish it: every +message — step assignments, verification requests, the final `DONE:` +report — must be sent with +`buzz messages send --channel --content `. Your turn is +not complete until you have published your message. Do not use the shell +for task work — that is your workers' job. + +Tasks arrive as a channel message from the user @mentioning you. Address +each assignment to a specific worker by @mention, exactly one worker per +step. You may assign independent steps to different workers, but never give +two workers overlapping or conflicting work — they share one task +environment and one filesystem. + +Rules: +1. Read the task instruction. Break it into the smallest concrete steps. +2. Assign each step to a worker with an @mention. One step per message. + State the exact goal and the success check, not just the command to run. + Relay the task's requirements verbatim — use the paths the task states, + and do not add constraints the task does not state (paths, encodings, + byte-level rules). Where the task is silent, let standard tool defaults + apply. +3. Wait for the worker's report before assigning the next dependent step. +4. When a worker reports output, verify it against the task's success + criteria before moving on: assign a verification step that runs the + task's own success check and shows real output. Assign each verification + step to a different worker than the one whose work is being verified — + independent verification, never self-review. Do not report completion on + a worker's claim alone. +5. When the task is complete and verified, report back to the user: publish + a final message starting with `DONE:` that @mentions the user and + summarizes what was produced and how it was verified. The task is not + finished until this message is published — never conclude silently. + +Keep messages short. Never fabricate command output. If a worker's report is +ambiguous, ask them to re-run with the exact verification command. diff --git a/benchmarks/harbor-buzz-orchestra/personas/worker-m1.md b/benchmarks/harbor-buzz-orchestra/personas/worker-m1.md new file mode 100644 index 0000000000..442d7b874d --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/personas/worker-m1.md @@ -0,0 +1,32 @@ +# Worker — M1 hello-world + +You are a worker agent with terminal access, coordinating over a Buzz channel. + +You work directly in the task environment: your `shell` tool runs +commands in it, and your file tools read and edit its files. The same +shell has the `buzz` CLI on PATH, already authenticated as you; reports go +to the channel with +`buzz messages send --channel --content `. Your team and your +channel id are listed in the "Your team" section below. Your turn is not +complete until you have published your report. + +The orchestrator only wakes for messages that @mention it. Every report +you publish must start with an @mention of the agent that assigned you the +step (use their name exactly as it appears in their message). If the +assignment's event id is visible in your context, also pass +`--reply-to ` to thread the report. A report that mentions +nobody is invisible and the task will stall. + +Rules: +1. Act only on steps assigned to you by the orchestrator's @mention. +2. Execute the requested step in the terminal. Prefer the smallest command + that achieves the stated goal. Create files in your terminal's current + working directory unless the task itself names a path. If an assignment + gives an absolute path your working directory does not contain, report + the mismatch instead of creating the directory — `mkdir -p` on an + invented path silently puts the file where the grader will never look. +3. Report back in one message: the command you ran, its exit code, and the + relevant output (trimmed, never invented). +4. If a command fails, report the failure verbatim and stop — do not + improvise a different approach without the orchestrator's direction. +5. Never claim success without showing the verifying output. diff --git a/benchmarks/harbor-buzz-orchestra/personas/worker-tb.md b/benchmarks/harbor-buzz-orchestra/personas/worker-tb.md new file mode 100644 index 0000000000..49a9125a17 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/personas/worker-tb.md @@ -0,0 +1,32 @@ +# Worker — Terminal-Bench team + +You are a worker agent with terminal access, coordinating over a Buzz +channel. Your team, your channel id, and your orchestrator are listed in +the "Your team" section below. + +You work directly in the task environment: your `shell` tool runs +commands in it, and your file tools read and edit its files. The same +shell has the `buzz` CLI on PATH, already authenticated as you; reports go +to the channel with +`buzz messages send --channel --content `. Your turn is +not complete until you have published your report. + +The orchestrator only wakes for messages that @mention it. Every report +you publish must start with an @mention of the agent that assigned you the +step (use their name exactly as it appears in their message). If the +assignment's event id is visible in your context, also pass +`--reply-to ` to thread the report. A report that mentions +nobody is invisible and the task will stall. + +Rules: +1. Act only on steps assigned to you by the orchestrator's @mention. If a + message assigns a step to a different worker, ignore it. +2. Execute the requested step in the terminal BEFORE writing any report. + Never describe output you have not yet produced. Prefer the smallest + command that achieves the stated goal. Use the paths the task or the + assignment states; do not invent paths. +3. Report back in one message: the command you ran, its exit code, and the + relevant output (trimmed, never invented). +4. If a command fails, report the failure verbatim and stop — do not + improvise a different approach without the orchestrator's direction. +5. Never claim success without showing the verifying output. diff --git a/benchmarks/harbor-buzz-orchestra/pyproject.toml b/benchmarks/harbor-buzz-orchestra/pyproject.toml new file mode 100644 index 0000000000..1dd2878040 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "harbor-buzz-orchestra" +version = "0.1.0" +description = "Harbor custom agent for Buzz-coordinated multi-agent benchmarks" +requires-python = ">=3.12" +dependencies = [ + "harbor>=0.16.1,<0.18", + "pydantic>=2.11", + "pyyaml>=6.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project.optional-dependencies] +dev = [ + "pytest>=8.4", + "pytest-asyncio>=1.2", + "ruff>=0.15", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.ruff] +target-version = "py312" +line-length = 88 diff --git a/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py b/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py new file mode 100755 index 0000000000..f1e91d022b --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +"""One-command benchmark: bring up the Buzz stack in Docker and run it. + +``just benchmark`` wraps this script. Defaults are leaderboard-eligible out +of the box (Terminal-Bench 2.1, 5 attempts per problem, the Sonnet+Haiku +team); every ``run_leaderboard.py`` selector passes through unchanged. The +script owns everything around the run: + +- A dedicated ``buzz-benchmark`` compose project reusing the production + bundle (``deploy/compose/compose.yml``) plus the benchmark port overlay, + on its own ports (relay :3600, Postgres :5633, metrics :9602) so it never + collides with a dev stack. Secrets and identities are generated once into + the gitignored ``.benchmark/`` state dir and reused across runs. +- One pinned *user* identity for the whole benchmark environment: it owns + every trial channel and posts every task, like one human running many + teams. Channels are kept (not archived) after each trial. +- ``--gui`` adds that user to the relay membership list and opens the Buzz + desktop app logged in as them, so a human can watch the teams work live. + +Run inside the testbed environment (the just recipe does this): + + uv run --project benchmarks/harbor-buzz-orchestra/testbed \ + benchmarks/harbor-buzz-orchestra/scripts/benchmark.py [--gui] [...] +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import secrets +import shutil +import subprocess +import sys +import time +from pathlib import Path + +PACKAGE_ROOT = Path(__file__).resolve().parent.parent +REPO_ROOT = PACKAGE_ROOT.parents[1] +STATE_DIR = PACKAGE_ROOT / ".benchmark" + +COMPOSE_PROJECT = "buzz-benchmark" +COMPOSE_FILES = ( + REPO_ROOT / "deploy" / "compose" / "compose.yml", + PACKAGE_ROOT / "testbed" / "compose.benchmark.yml", +) +RELAY_HTTP_PORT = 3600 +PG_HOST_PORT = 5633 +METRICS_HOST_PORT = 9602 +GUI_BUNDLE_IDENTIFIER = "xyz.block.buzz.app.benchmark" + +DEFAULT_DATASET = "terminal-bench/terminal-bench-2-1" +DEFAULT_ATTEMPTS = 5 +DEFAULT_MANIFEST = PACKAGE_ROOT / "manifests" / "tb-cobol-sonnet-haiku.yaml" +DEFAULT_ENDPOINTS = PACKAGE_ROOT / "testbed" / "endpoints" / "anthropic-live.json" +SCHEMA_SQL = PACKAGE_ROOT / "testbed" / "sql" / "benchmark_schema.sql" + +# Linux builds of the production agent stack, uploaded into each task +# container per trial. Built once in a rust:alpine container (musl → fully +# static, runs on any Linux task image of the same architecture) and cached. +AGENT_BINARIES = ("buzz-acp", "buzz-agent", "buzz-dev-mcp") +# Std-only loopback forwarder (not a workspace crate): agents dial the +# relay's canonical localhost address inside the task container and the +# forwarder bridges to the Docker host gateway. Compiled with plain rustc +# in the same cross-build step. +FORWARDER_SOURCE = PACKAGE_ROOT / "forwarder" / "relay_forwarder.rs" +FORWARDER_BINARY = "relay-forwarder" +LINUX_TARGET_DIR = STATE_DIR / "linux-target" +RUST_IMAGE = "rust:1.95-alpine" + +_spec = importlib.util.spec_from_file_location( + "run_leaderboard", Path(__file__).resolve().parent / "run_leaderboard.py" +) +run_leaderboard = importlib.util.module_from_spec(_spec) +sys.modules.setdefault("run_leaderboard", run_leaderboard) +_spec.loader.exec_module(run_leaderboard) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=__doc__.splitlines()[0], + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + problems = parser.add_mutually_exclusive_group() + problems.add_argument( + "--dataset", "-d", default=None, + help=f"Registry dataset (default: {DEFAULT_DATASET})", + ) + problems.add_argument( + "--path", "-p", type=Path, help="Local task or dataset directory" + ) + parser.add_argument( + "--include-task", "-i", action="append", default=[], + help="Task name to include (glob, repeatable)", + ) + parser.add_argument( + "--exclude-task", "-x", action="append", default=[], + help="Task name to exclude (glob, repeatable)", + ) + parser.add_argument( + "--attempts", "-k", type=int, default=DEFAULT_ATTEMPTS, + help=f"Runs per problem (default: {DEFAULT_ATTEMPTS}, the leaderboard requirement)", + ) + parser.add_argument( + "--manifest", type=Path, default=DEFAULT_MANIFEST, + help=f"Team manifest YAML (default: {DEFAULT_MANIFEST.name})", + ) + parser.add_argument( + "--endpoint-config", type=Path, default=DEFAULT_ENDPOINTS, + help=f"Endpoint provider/API-key mapping (default: {DEFAULT_ENDPOINTS.name})", + ) + parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials") + parser.add_argument( + "--jobs-dir", type=Path, default=PACKAGE_ROOT / "jobs", help="Job output root" + ) + parser.add_argument("--job-name", default=None, help="Job name (default: lb--)") + parser.add_argument( + "--upload", action="store_true", help="Upload to Harbor Hub when the job finishes" + ) + parser.add_argument( + "--gui", action="store_true", + help="Open the Buzz desktop app as the benchmark user to watch the run live", + ) + parser.add_argument( + "--fresh", action="store_true", + help="Reset first: drop the stack's Docker volumes and the benchmark " + "GUI's app state (keys in state.json are kept)", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="Print the underlying harbor command and exit (no stack bring-up)", + ) + return parser.parse_args(argv) + + +# -- state: secrets and identities, generated once -------------------------- + + +def load_state() -> dict[str, str]: + """Generate-or-load the benchmark environment's keys and secrets.""" + from harbor_buzz_testbed.keys import generate_keypair, keypair_from_secret + + STATE_DIR.mkdir(mode=0o700, exist_ok=True) + state_path = STATE_DIR / "state.json" + if state_path.is_file(): + state = json.loads(state_path.read_text()) + else: + owner = generate_keypair() + user = generate_keypair() + state = { + "owner_secret_key": owner.secret_key, + "user_secret_key": user.secret_key, + "postgres_password": secrets.token_urlsafe(24), + "redis_password": secrets.token_urlsafe(24), + "typesense_api_key": secrets.token_hex(16), + "s3_access_key": secrets.token_hex(10), + "s3_secret_key": secrets.token_hex(20), + "git_hook_hmac_secret": secrets.token_hex(32), + "relay_private_key": generate_keypair().secret_key, + } + state_path.touch(mode=0o600) + state_path.write_text(json.dumps(state, indent=2)) + state["owner_pubkey"] = keypair_from_secret(state["owner_secret_key"]).pubkey + state["user_pubkey"] = keypair_from_secret(state["user_secret_key"]).pubkey + return state + + +def print_user_identity(state: dict[str, str]) -> None: + """Show the pinned benchmark user's key so a human can import it during + the desktop GUI's onboarding (this stack is local-only; the key guards + nothing beyond it).""" + from harbor_buzz_testbed.keys import encode_nsec + + print( + f"benchmark user pubkey: {state['user_pubkey']}\n" + f"benchmark user nsec: {encode_nsec(state['user_secret_key'])} " + "(import this in the GUI onboarding to watch as the benchmark user)" + ) + + +def write_env_file(state: dict[str, str]) -> Path: + """Compose interpolation env — regenerated from state on every run.""" + env_path = STATE_DIR / ".env" + lines = { + "BUZZ_IMAGE": os.environ.get("BUZZ_IMAGE", "ghcr.io/block/buzz:main"), + "BUZZ_DOMAIN": "localhost", + "RELAY_URL": f"ws://localhost:{RELAY_HTTP_PORT}", + "BUZZ_MEDIA_BASE_URL": f"http://localhost:{RELAY_HTTP_PORT}/media", + "BUZZ_MEDIA_SERVER_DOMAIN": "localhost", + "BUZZ_CORS_ORIGINS": f"http://localhost:{RELAY_HTTP_PORT}", + "BUZZ_REQUIRE_AUTH_TOKEN": "true", + "BUZZ_REQUIRE_RELAY_MEMBERSHIP": "true", + "BUZZ_ALLOW_NIP_OA_AUTH": "true", + "BUZZ_AUTO_MIGRATE": "true", + "BUZZ_GIT_CONFORMANCE_PROBE": "true", + "RUST_LOG": "buzz_relay=info,buzz_db=info,buzz_auth=info", + "RELAY_OWNER_PUBKEY": state["owner_pubkey"], + "BUZZ_RELAY_PRIVATE_KEY": state["relay_private_key"], + "BUZZ_GIT_HOOK_HMAC_SECRET": state["git_hook_hmac_secret"], + "POSTGRES_DB": "buzz", + "POSTGRES_USER": "buzz", + "POSTGRES_PASSWORD": state["postgres_password"], + "REDIS_PASSWORD": state["redis_password"], + "TYPESENSE_API_KEY": state["typesense_api_key"], + "BUZZ_S3_ACCESS_KEY": state["s3_access_key"], + "BUZZ_S3_SECRET_KEY": state["s3_secret_key"], + "BUZZ_S3_BUCKET": "buzz-media", + "BUZZ_HTTP_PORT": str(RELAY_HTTP_PORT), + "BUZZ_PG_HOST_PORT": str(PG_HOST_PORT), + "BUZZ_METRICS_HOST_PORT": str(METRICS_HOST_PORT), + } + env_path.touch(mode=0o600) + env_path.write_text("".join(f"{k}={v}\n" for k, v in lines.items())) + return env_path + + +def postgres_dsn(state: dict[str, str]) -> str: + return ( + f"postgresql://buzz:{state['postgres_password']}" + f"@127.0.0.1:{PG_HOST_PORT}/buzz" + ) + + +def write_provisioner_config( + state: dict[str, str], endpoint_config: Path +) -> Path: + """Resolve per-endpoint API keys from the environment and write the + provisioner config: pinned user, keep-channels teardown.""" + endpoints = json.loads(endpoint_config.read_text()) + llm_api_keys: dict[str, str] = {} + for name, entry in endpoints.items(): + env_var = entry["api_key_env"] + key = os.environ.get(env_var) + if not key: + raise SystemExit( + f"endpoint {name!r} needs the {env_var} environment variable" + ) + llm_api_keys[name] = key + config = { + "relay_http_url": f"http://localhost:{RELAY_HTTP_PORT}", + # The agents dial the relay's CANONICAL address — the relay is + # host-header tenant-bound, and its community row is the authority + # of RELAY_URL (localhost:3600). Inside the task container that + # loopback address is served by a tiny forwarder bridging to the + # Docker host gateway (see --relay-gateway below). + "relay_ws_url": f"ws://localhost:{RELAY_HTTP_PORT}", + "owner_secret_key": state["owner_secret_key"], + "postgres_dsn": postgres_dsn(state), + "llm_api_keys": llm_api_keys, + "user_secret_key": state["user_secret_key"], + "archive_on_teardown": False, + } + path = STATE_DIR / "provisioner.json" + path.touch(mode=0o600) + path.write_text(json.dumps(config, indent=2)) + return path + + +# -- docker stack ------------------------------------------------------------ + + +def compose_command(*args: str) -> list[str]: + command = [ + "docker", "compose", + "--project-name", COMPOSE_PROJECT, + "--project-directory", str(STATE_DIR), + "--env-file", str(STATE_DIR / ".env"), + ] + for file in COMPOSE_FILES: + command += ["-f", str(file)] + return command + list(args) + + +def stale_credential_volume(state: dict[str, str]) -> bool: + """True when Postgres is up but rejects THIS clone's password — the + volume was initialized by another checkout's ``.benchmark/`` state + (compose project name is machine-global, state dir is per-clone).""" + import psycopg + + try: + psycopg.connect(postgres_dsn(state), connect_timeout=5).close() + except psycopg.OperationalError as error: + return "password authentication failed" in str(error) + return False + + +def bring_up_stack(state: dict[str, str]) -> None: + """Compose bring-up (idempotent), self-healing the one known-fatal + failure: a stale Postgres volume from a different clone. Nothing in + that volume is usable (we can't even authenticate to it), so drop the + volumes and retry once rather than aborting with instructions.""" + try: + subprocess.run(compose_command("up", "-d", "--wait"), check=True) + except subprocess.CalledProcessError: + if not stale_credential_volume(state): + raise + print( + "benchmark Postgres volume was initialized by a different " + "checkout's .benchmark/ state — dropping the stale volumes and " + "retrying..." + ) + subprocess.run(compose_command("down", "-v"), check=True) + subprocess.run(compose_command("up", "-d", "--wait"), check=True) + + +def reset_environment() -> None: + """--fresh: drop the stack's Docker volumes and the benchmark GUI's + app state, together — GUI records (workspaces, read state) only stay + coherent as long as the database they reference exists. Keys in + ``state.json`` are kept, so the same nsec works after the reset.""" + subprocess.run(compose_command("down", "-v"), check=True) + if sys.platform == "darwin": + for domain in ("WebKit", "Caches", "Application Support"): + shutil.rmtree( + Path.home() / "Library" / domain / GUI_BUNDLE_IDENTIFIER, + ignore_errors=True, + ) + + +def ensure_stack(state: dict[str, str]) -> None: + """Bring the compose stack up (idempotent) and apply the benchmark schema.""" + import psycopg + + bring_up_stack(state) + + deadline = time.monotonic() + 60 + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + with psycopg.connect(postgres_dsn(state)) as conn: + conn.execute(SCHEMA_SQL.read_text()) + conn.commit() + return + except psycopg.Error as error: # containers healthy but PG settling + last_error = error + time.sleep(2) + raise SystemExit(f"benchmark schema apply failed: {last_error}") + + +# -- buzz binaries ----------------------------------------------------------- + + +def ensure_binaries() -> dict[str, Path]: + """Find the host buzz CLI, building it once if missing.""" + try: + return run_leaderboard.find_binaries(None) + except SystemExit: + print("host buzz CLI missing — building (cargo build, first run only)...") + cargo = REPO_ROOT / "bin" / "cargo" + subprocess.run( + [str(cargo), "build", "-p", "buzz-cli"], + cwd=REPO_ROOT, + check=True, + ) + return run_leaderboard.find_binaries(None) + + +def linux_triple() -> str: + """The musl triple matching the Docker engine that runs task containers.""" + arch = subprocess.run( + ["docker", "version", "--format", "{{.Server.Arch}}"], + capture_output=True, text=True, check=True, + ).stdout.strip() + try: + return { + "arm64": "aarch64-unknown-linux-musl", + "amd64": "x86_64-unknown-linux-musl", + }[arch] + except KeyError: + raise SystemExit(f"unsupported Docker architecture: {arch!r}") from None + + +def ensure_agent_binaries() -> Path: + """Cross-build the static Linux agent stack once, cached in .benchmark/. + + The agents run *inside* each Harbor task container as the real + buzz-acp → buzz-agent → buzz-dev-mcp stack, so the binaries must be + Linux ELF for the task image architecture. musl-static means they run + on any Linux base image (glibc or not). The relay loopback forwarder + is compiled in the same step with plain rustc (std-only, no deps). + """ + triple = linux_triple() + bin_dir = LINUX_TARGET_DIR / triple / "release" + targets = AGENT_BINARIES + (FORWARDER_BINARY,) + if all((bin_dir / name).is_file() for name in targets): + return bin_dir + print(f"Linux agent binaries missing — cross-building for {triple} " + f"in {RUST_IMAGE} (first run only, ~2 min)...") + LINUX_TARGET_DIR.mkdir(parents=True, exist_ok=True) + (STATE_DIR / "cargo-registry").mkdir(exist_ok=True) + packages = [arg for name in AGENT_BINARIES for arg in ("-p", name)] + forwarder_src = FORWARDER_SOURCE.relative_to(REPO_ROOT) + subprocess.run( + [ + "docker", "run", "--rm", + "-v", f"{REPO_ROOT}:/src:ro", + "-v", f"{LINUX_TARGET_DIR}:/target", + "-v", f"{STATE_DIR / 'cargo-registry'}:/usr/local/cargo/registry", + "-e", "CARGO_TARGET_DIR=/target", + "-w", "/src", + RUST_IMAGE, + "sh", "-c", + "apk add --no-cache musl-dev >/dev/null && " + f"cargo build --release --locked --target {triple} " + + " ".join(packages) + + f" && rustc --edition 2021 -O --target {triple}" + f" -o /target/{triple}/release/{FORWARDER_BINARY}" + f" /src/{forwarder_src}", + ], + check=True, + ) + missing = [n for n in targets if not (bin_dir / n).is_file()] + if missing: + raise SystemExit(f"cross-build produced no {', '.join(missing)} in {bin_dir}") + return bin_dir + + +# -- GUI --------------------------------------------------------------------- + + +def launch_gui(state: dict[str, str]) -> subprocess.Popen: + """Open the Buzz desktop app logged in as the benchmark user. + + The relay runs closed (membership required), so the user pubkey is first + added to the relay membership list via buzz-admin inside the container — + NIP-OA auth tags cover the agents, but the GUI authenticates as a plain + member, exactly like a human. + """ + subprocess.run( + compose_command( + "exec", "-T", "relay", + "buzz-admin", "add-member", "--pubkey", state["user_pubkey"], + ), + check=True, + ) + + desktop_dir = REPO_ROOT / "desktop" + if not (desktop_dir / "node_modules").is_dir(): + subprocess.run(["pnpm", "install"], cwd=desktop_dir, check=True) + + # tauri dev needs sidecar files present; stub them and drop in the real + # CLI binary (mirrors the just staging recipe). + target = subprocess.run( + ["rustc", "-vV"], capture_output=True, text=True, check=True + ).stdout + triple = next( + line.split(": ", 1)[1] for line in target.splitlines() if line.startswith("host: ") + ) + sidecar_dir = desktop_dir / "src-tauri" / "binaries" + sidecar_dir.mkdir(parents=True, exist_ok=True) + binaries = ensure_binaries() + for name in ("buzz-acp", "buzz-agent", "buzz-dev-mcp", "git-credential-nostr", "buzz"): + stub = sidecar_dir / f"{name}-{triple}" + if not stub.exists(): + stub.touch() + real_cli = sidecar_dir / f"buzz-{triple}" + real_cli.write_bytes(binaries["buzz"].read_bytes()) + real_cli.chmod(0o755) + + print( + f"Opening Buzz GUI as the benchmark user ({state['user_pubkey'][:16]}…).\n" + "Watch, don't type — a message from you mid-trial would taint the run." + ) + # Distinct bundle identifier: the desktop app persists workspaces (incl. + # their relay URLs) in per-identifier WebKit localStorage, and a stored + # workspace's relay URL overrides BUZZ_RELAY_URL by design. Reusing the + # default identifier means any past local-dev session's ws://localhost:3000 + # workspace silently shadows the benchmark relay. An identifier of our own + # keeps that state isolated both ways. + tauri_config = json.dumps( + {"identifier": GUI_BUNDLE_IDENTIFIER, "productName": "Buzz Benchmark"} + ) + return subprocess.Popen( + ["pnpm", "exec", "tauri", "dev", "--config", tauri_config], + cwd=desktop_dir, + env={ + **os.environ, + "BUZZ_RELAY_URL": f"ws://localhost:{RELAY_HTTP_PORT}", + "BUZZ_PRIVATE_KEY": state["user_secret_key"], + }, + ) + + +# -- main --------------------------------------------------------------------- + + +def leaderboard_argv( + args: argparse.Namespace, provisioner_config: Path, agent_bin_dir: Path +) -> list[str]: + argv: list[str] = [] + if args.path: + argv += ["--path", str(args.path)] + else: + argv += ["--dataset", args.dataset or DEFAULT_DATASET] + for pattern in args.include_task: + argv += ["--include-task", pattern] + for pattern in args.exclude_task: + argv += ["--exclude-task", pattern] + argv += [ + "--attempts", str(args.attempts), + "--manifest", str(args.manifest), + "--endpoint-config", str(args.endpoint_config), + "--provisioner-config", str(provisioner_config), + "--agent-bin-dir", str(agent_bin_dir), + # The relay as reachable from inside a task container: Docker's + # host alias, bridged to the canonical localhost address by the + # uploaded forwarder. Override the alias with + # BUZZ_BENCHMARK_DOCKER_HOST if your engine exposes the host + # differently. + "--relay-gateway", + f"{os.environ.get('BUZZ_BENCHMARK_DOCKER_HOST', 'host.docker.internal')}" + f":{RELAY_HTTP_PORT}", + "--n-concurrent", str(args.n_concurrent), + "--jobs-dir", str(args.jobs_dir), + ] + if args.job_name: + argv += ["--job-name", args.job_name] + if args.upload: + argv.append("--upload") + if args.dry_run: + argv.append("--dry-run") + return argv + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + state = load_state() + print_user_identity(state) + write_env_file(state) + provisioner_config = write_provisioner_config(state, args.endpoint_config) + + if args.dry_run: + agent_bin_dir = LINUX_TARGET_DIR / linux_triple() / "release" + else: + ensure_binaries() + agent_bin_dir = ensure_agent_binaries() + if args.fresh: + reset_environment() + ensure_stack(state) + if args.gui: + launch_gui(state) + + return run_leaderboard.main( + leaderboard_argv(args, provisioner_config, agent_bin_dir) + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py b/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py new file mode 100755 index 0000000000..6fd43ea6fb --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Run a problem set with a team manifest and produce leaderboard-ready results. + +One command wraps ``harbor run`` with only leaderboard-legal settings — no +timeout or resource overrides are accepted or forwarded, so the resulting job +directory passes Harbor's static validation as produced. After the run it +writes a ``metadata.yaml`` template derived from the manifest and prints the +exact upload/submit commands. + +Run inside the testbed environment so ``harbor`` and the adapter are +importable: + + uv run --project benchmarks/harbor-buzz-orchestra/testbed \ + benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py \ + --dataset terminal-bench/terminal-bench-2-1 \ + --attempts 5 \ + --manifest benchmarks/harbor-buzz-orchestra/manifests/.yaml \ + --endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/.json \ + --provisioner-config \ + --agent-bin-dir +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import yaml + +PACKAGE_ROOT = Path(__file__).resolve().parent.parent +AGENT_IMPORT = "harbor_buzz_orchestra:BuzzOrchestraAgent" +PROVISIONER_FACTORY = "harbor_buzz_testbed:provisioner_from_dict" +# Host-side: the harness speaks to the relay as the trial user via this CLI. +BINARIES = ("buzz",) +# Container-side: the production stack uploaded into each task container. +# These must be Linux builds matching the task image architecture. +AGENT_BINARIES = ("buzz-acp", "buzz-agent", "buzz-dev-mcp") +# Uploaded alongside the stack when --relay-gateway is set: bridges the +# agents' canonical relay address to the host gateway (the relay is +# host-header tenant-bound, so agents must present its canonical Host). +FORWARDER_BINARY = "relay-forwarder" + +PROVIDER_ORGS = {"anthropic": "Anthropic", "openai": "OpenAI", "databricks": "Databricks"} + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter + ) + problems = parser.add_mutually_exclusive_group(required=True) + problems.add_argument( + "--dataset", "-d", help="Registry dataset (e.g. terminal-bench/terminal-bench-2-1)" + ) + problems.add_argument( + "--path", "-p", type=Path, help="Local task or dataset directory" + ) + parser.add_argument( + "--include-task", "-i", action="append", default=[], + help="Task name to include from the dataset (glob, repeatable)", + ) + parser.add_argument( + "--exclude-task", "-x", action="append", default=[], + help="Task name to exclude from the dataset (glob, repeatable)", + ) + parser.add_argument( + "--attempts", "-k", type=int, required=True, + help="Runs per problem (leaderboards require 5)", + ) + parser.add_argument("--manifest", type=Path, required=True, help="Team manifest YAML") + parser.add_argument( + "--endpoint-config", type=Path, required=True, + help="JSON mapping manifest endpoint names to providers/API keys", + ) + parser.add_argument( + "--provisioner-config", type=Path, required=True, + help="JSON config for the Buzz relay/Postgres provisioner", + ) + parser.add_argument( + "--buzz-bin-dir", type=Path, default=None, + help="Directory with the host buzz CLI (default: repo target/release, then target/debug)", + ) + parser.add_argument( + "--agent-bin-dir", type=Path, required=True, + help="Directory with Linux builds of buzz-acp/buzz-agent/buzz-dev-mcp " + "to upload into each task container", + ) + parser.add_argument( + "--relay-gateway", default="", + help="host:port of the benchmark relay as reachable from inside the " + "task container (e.g. host.docker.internal:3600). When set, a " + "loopback forwarder from --agent-bin-dir bridges the canonical " + "relay address to this gateway", + ) + parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials") + parser.add_argument("--jobs-dir", type=Path, default=Path("jobs"), help="Job output root") + parser.add_argument("--job-name", default=None, help="Job name (default: lb--)") + parser.add_argument( + "--upload", action="store_true", help="Upload to Harbor Hub when the job finishes" + ) + parser.add_argument("--dry-run", action="store_true", help="Print the harbor command and exit") + return parser.parse_args(argv) + + +def find_binaries(bin_dir: Path | None) -> dict[str, Path]: + candidates = ( + [bin_dir] + if bin_dir is not None + else [PACKAGE_ROOT.parents[1] / "target" / kind for kind in ("release", "debug")] + ) + for candidate in candidates: + found = {name: candidate / name for name in BINARIES} + if all(path.is_file() for path in found.values()): + return found + searched = ", ".join(str(c) for c in candidates) + raise SystemExit( + f"buzz binaries not found (need {', '.join(BINARIES)}; searched {searched}). " + "Build them with `cargo build` or pass --buzz-bin-dir." + ) + + +def find_agent_binaries(bin_dir: Path, with_forwarder: bool = False) -> dict[str, Path]: + """The Linux agent stack uploaded into each task container.""" + names = AGENT_BINARIES + ((FORWARDER_BINARY,) if with_forwarder else ()) + found = {name: bin_dir / name for name in names} + missing = [name for name, path in found.items() if not path.is_file()] + if missing: + raise SystemExit( + f"Linux agent binaries not found in {bin_dir}: {', '.join(missing)}. " + "`just benchmark` builds them; or cross-compile with " + "cargo --target -unknown-linux-musl and pass --agent-bin-dir." + ) + return found + + +def build_command( + args: argparse.Namespace, + binaries: dict[str, Path], + agent_binaries: dict[str, Path], +) -> list[str]: + """Compose the harbor invocation. Standard settings only: any timeout or + resource override would fail leaderboard static validation, so none are + accepted or forwarded.""" + command = [ + "harbor", "run", "--yes", + "--job-name", args.job_name, + "--jobs-dir", str(args.jobs_dir), + "-k", str(args.attempts), + "--n-concurrent", str(args.n_concurrent), + ] + if args.dataset: + command += ["--dataset", args.dataset] + else: + command += ["--path", str(args.path)] + for pattern in args.include_task: + command += ["--include-task-name", pattern] + for pattern in args.exclude_task: + command += ["--exclude-task-name", pattern] + if args.upload: + command.append("--upload") + command += ["--agent", AGENT_IMPORT] + kwargs = { + "manifest": args.manifest, + "provisioner_factory": PROVISIONER_FACTORY, + "provisioner_config": args.provisioner_config, + "artifact_root": PACKAGE_ROOT, + "endpoint_config": args.endpoint_config, + "buzz_acp_binary": agent_binaries["buzz-acp"], + "buzz_agent_binary": agent_binaries["buzz-agent"], + "buzz_dev_mcp_binary": agent_binaries["buzz-dev-mcp"], + "buzz_cli_binary": binaries["buzz"], + "run_id": args.job_name, + } + if args.relay_gateway: + kwargs["relay_gateway"] = args.relay_gateway + kwargs["forwarder_binary"] = agent_binaries[FORWARDER_BINARY] + for key, value in kwargs.items(): + command += ["--agent-kwarg", f"{key}={value}"] + return command + + +def write_metadata_template(args: argparse.Namespace, job_dir: Path) -> Path: + """Derive a metadata.yaml template (harbor.leaderboard.metadata schema) + from the manifest roster; display-name placeholders are for the submitter + to confirm.""" + manifest = yaml.safe_load(args.manifest.read_text()) + endpoints = json.loads(args.endpoint_config.read_text()) + models, seen = [], set() + for entry in manifest.get("roster", []): + model = entry.get("model_revision") or entry["endpoint"] + if model in seen: + continue + seen.add(model) + provider = endpoints.get(entry["endpoint"], {}).get("provider", "FILL_ME") + models.append( + { + "model_name": model, + "model_provider": provider, + "model_display_name": model, + "model_org_display_name": PROVIDER_ORGS.get(provider, "FILL_ME"), + } + ) + metadata = { + "agent_url": "https://github.com/block/buzz", + "agent_display_name": f"Buzz Orchestra ({manifest.get('condition', 'team')})", + "agent_org_display_name": "Block", + "models": models, + } + path = job_dir / "metadata.yaml" + path.write_text(yaml.safe_dump(metadata, sort_keys=False)) + return path + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + for label, path in ( + ("manifest", args.manifest), + ("endpoint config", args.endpoint_config), + ("provisioner config", args.provisioner_config), + ): + if not path.is_file(): + raise SystemExit(f"{label} not found: {path}") + if args.job_name is None: + condition = yaml.safe_load(args.manifest.read_text()).get("condition", "team") + stamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ") + args.job_name = f"lb-{condition}-{stamp}" + + if args.dry_run: + # Dry runs print the command without requiring built binaries. + bin_dir = args.buzz_bin_dir or PACKAGE_ROOT.parents[1] / "target" / "release" + binaries = {name: bin_dir / name for name in BINARIES} + agent_binaries = { + name: args.agent_bin_dir / name + for name in AGENT_BINARIES + (FORWARDER_BINARY,) + } + print(" ".join(build_command(args, binaries, agent_binaries))) + return 0 + binaries = find_binaries(args.buzz_bin_dir) + agent_binaries = find_agent_binaries( + args.agent_bin_dir, with_forwarder=bool(args.relay_gateway) + ) + command = build_command(args, binaries, agent_binaries) + if shutil.which("harbor") is None: + raise SystemExit( + "harbor not on PATH — run via: uv run --project " + f"{PACKAGE_ROOT / 'testbed'} {Path(__file__).resolve()} ..." + ) + + result = subprocess.run(command) + job_dir = args.jobs_dir / args.job_name + if result.returncode != 0: + print(f"harbor run failed (exit {result.returncode}); job dir: {job_dir}") + return result.returncode + + metadata_path = write_metadata_template(args, job_dir) + print("\nLeaderboard-ready job complete.") + print(f" 1. Review submitter details in {metadata_path}") + print(f" 2. harbor upload {job_dir}") + print( + " 3. harbor leaderboard submit -l terminal-bench/terminal-bench-2-1 " + f"-j -m {metadata_path}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py new file mode 100644 index 0000000000..b423e8aa47 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py @@ -0,0 +1,25 @@ +"""Buzz orchestra custom agent for Harbor.""" + +from .agent import BuzzOrchestraAgent +from .manifest import ExperimentManifest, ManifestError +from .provisioning import AgentCredential, TrialHandle, TrialProvisioner +from .runtime import OrchestraRuntime, RuntimeResult +from .container_runtime import ( + BuzzContainerRuntime, + EndpointLaunchConfig, + RuntimeLaunchError, +) + +__all__ = [ + "AgentCredential", + "BuzzOrchestraAgent", + "BuzzContainerRuntime", + "EndpointLaunchConfig", + "ExperimentManifest", + "ManifestError", + "OrchestraRuntime", + "RuntimeResult", + "RuntimeLaunchError", + "TrialHandle", + "TrialProvisioner", +] diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py new file mode 100644 index 0000000000..6354e9a587 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py @@ -0,0 +1,200 @@ +"""Harbor custom-agent entry point for Buzz orchestration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from harbor.agents.base import BaseAgent +from harbor.environments.base import BaseEnvironment +from harbor.models.agent.context import AgentContext + +from .manifest import ExperimentManifest +from .provisioning import TrialProvisioner +from .runtime import OrchestraRuntime +from .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig + + +class BuzzOrchestraAgent(BaseAgent): + """Coordinate an arbitrary manifest-defined team through a Buzz trial.""" + + # Set True only once the runtime writes a validated agent/trajectory.json. + SUPPORTS_ATIF = False + + def __init__( + self, + logs_dir: Path, + model_name: str | None = None, + *, + manifest: str | Path | dict[str, Any], + provisioner: TrialProvisioner | None = None, + runtime: OrchestraRuntime | None = None, + provisioner_factory: str | None = None, + provisioner_config: str | Path | dict[str, Any] | None = None, + artifact_root: str | Path | None = None, + endpoint_config: str | Path | dict[str, Any] | None = None, + buzz_acp_binary: str = "buzz-acp", + buzz_agent_binary: str = "buzz-agent", + buzz_dev_mcp_binary: str = "buzz-dev-mcp", + buzz_cli_binary: str = "buzz", + relay_gateway: str = "", + forwarder_binary: str = "relay-forwarder", + run_id: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(logs_dir=logs_dir, model_name=model_name, **kwargs) + self.manifest = ExperimentManifest.load(manifest) + self.provisioner = provisioner or self._build_provisioner( + provisioner_factory, provisioner_config + ) + self.runtime = runtime or self._build_runtime( + logs_dir, + artifact_root, + endpoint_config, + buzz_acp_binary, + buzz_agent_binary, + buzz_dev_mcp_binary, + buzz_cli_binary, + relay_gateway, + forwarder_binary, + ) + self.run_id = run_id + + @staticmethod + def name() -> str: + return "buzz-orchestra" + + def version(self) -> str: + return "0.1.0" + + @staticmethod + def _load_mapping( + source: str | Path | dict[str, Any] | None, + ) -> dict[str, Any] | None: + if source is None: + return None + if isinstance(source, dict): + return source + import json + + path = Path(source).expanduser() + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"cannot load JSON config {path}: {error}") from error + if not isinstance(value, dict): + raise ValueError(f"JSON config {path} must contain an object") + return value + + @classmethod + def _build_provisioner( + cls, + factory_path: str | None, + config_source: str | Path | dict[str, Any] | None, + ) -> TrialProvisioner | None: + config = cls._load_mapping(config_source) + if factory_path is None and config is None: + return None + if factory_path is None or config is None: + raise ValueError( + "provisioner_factory and provisioner_config must be provided together" + ) + from harbor.utils.import_path import import_symbol + + factory = import_symbol(factory_path) + return factory(config) + + @classmethod + def _build_runtime( + cls, + logs_dir: Path, + artifact_root: str | Path | None, + endpoint_source: str | Path | dict[str, Any] | None, + buzz_acp_binary: str, + buzz_agent_binary: str, + buzz_dev_mcp_binary: str, + buzz_cli_binary: str, + relay_gateway: str, + forwarder_binary: str, + ) -> OrchestraRuntime | None: + endpoint_data = cls._load_mapping(endpoint_source) + if endpoint_data is None and artifact_root is None: + return None + if endpoint_data is None or artifact_root is None: + raise ValueError( + "artifact_root and endpoint_config must be provided together" + ) + endpoints = { + name: EndpointLaunchConfig( + provider=value["provider"], + api_key_env=value["api_key_env"], + env=value.get("env", {}), + ) + for name, value in endpoint_data.items() + } + return BuzzContainerRuntime( + logs_dir=logs_dir, + artifact_root=Path(artifact_root), + endpoints=endpoints, + buzz_acp_binary=buzz_acp_binary, + buzz_agent_binary=buzz_agent_binary, + buzz_dev_mcp_binary=buzz_dev_mcp_binary, + buzz_cli_binary=buzz_cli_binary, + relay_gateway=relay_gateway, + forwarder_binary=forwarder_binary, + ) + + async def setup(self, environment: BaseEnvironment) -> None: + """Fail fast when the provisioner is configured but its stack is unhealthy.""" + if self.provisioner is not None: + self.provisioner.healthcheck() + + async def run( + self, + instruction: str, + environment: BaseEnvironment, + context: AgentContext, + ) -> None: + if self.provisioner is None or self.runtime is None: + raise RuntimeError( + "BuzzOrchestraAgent requires provisioner and runtime integrations; " + "the adapter contract is installed but M1 wiring is incomplete" + ) + + context_id = self.context_id or environment.context_id + if context_id is None: + raise RuntimeError("Harbor context_id is required as the trial join key") + trial_id = str(context_id) + run_id = self.run_id or trial_id + # Human-readable channel label: the task short name, so a spectator + # GUI shows one recognisable channel per problem per attempt. + channel_label = getattr(environment, "environment_name", None) + handle = self.provisioner.create_trial( + run_id, trial_id, self.manifest, channel_label=channel_label + ) + if handle.trial_id != trial_id: + raise RuntimeError("provisioner returned a handle for a different trial_id") + if handle.manifest_hash != self.manifest.sha256: + raise RuntimeError("provisioner returned a handle for a different manifest") + try: + result = await self.runtime.run( + instruction=instruction, + environment=environment, + manifest=self.manifest, + trial=handle, + ) + finally: + self.provisioner.teardown(handle) + + context.n_input_tokens = result.input_tokens + context.n_cache_tokens = result.cached_input_tokens + context.n_output_tokens = result.output_tokens + context.cost_usd = result.cost_usd + context.metadata = { + **result.metadata, + "manifest_sha256": self.manifest.sha256, + "condition": self.manifest.condition, + "buzz_channel_id": handle.channel_id, + "run_id": run_id, + "trial_id": trial_id, + } diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py new file mode 100644 index 0000000000..3909f081f5 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py @@ -0,0 +1,651 @@ +"""Run the production Buzz agent stack inside the Harbor task container. + +Each provisioned identity is a full ``buzz-acp`` → ``buzz-agent`` → +``buzz-dev-mcp`` process tree launched *inside* the task container — the same +binaries and the same MCP toolset (shell, file tools, the ``buzz`` CLI on +PATH) that the desktop app gives a Buzz agent. The harness stays outside: +it provisions, uploads the pinned binaries, posts the task as the trial +user, and observes the channel until the orchestrator publishes DONE. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shlex +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from harbor.environments.base import BaseEnvironment + +from .manifest import AgentClass, ExperimentManifest +from .provisioning import AgentCredential, TrialHandle +from .runtime import RuntimeResult + + +DEFAULT_MAX_AGENT_ROUNDS = 32 +# Container-side layout for the uploaded Buzz stack. +REMOTE_ROOT = "/opt/buzz" +REMOTE_BIN = f"{REMOTE_ROOT}/bin" +REMOTE_PROMPTS = f"{REMOTE_ROOT}/prompts" +REMOTE_LOGS = f"{REMOTE_ROOT}/logs" +# The relay is host-header tenant-bound (its community row is the authority +# of its own RELAY_URL), so agents must present that exact Host. When the +# relay actually lives outside the container, this forwarder listens on the +# canonical loopback address and bridges the byte stream to the gateway. +FORWARDER = f"{REMOTE_BIN}/relay-forwarder" +FORWARDER_LOG = f"{REMOTE_LOGS}/relay-forwarder.log" +# How many done-poll iterations between in-container liveness probes. +LIVENESS_EVERY = 10 + + +class RuntimeLaunchError(RuntimeError): + """Raised when a Buzz agent process cannot be launched or exits early.""" + + +@dataclass(frozen=True, slots=True) +class EndpointLaunchConfig: + """Deployment-specific environment needed to launch one manifest endpoint.""" + + provider: str + api_key_env: str + env: dict[str, str] = field(default_factory=dict) + + +@dataclass(slots=True) +class _Agent: + credential: AgentCredential + pid: int + stdout_log: str # container path + stderr_log: str # container path + + +class BuzzContainerRuntime: + """Launch one production Buzz agent stack per identity in the container.""" + + def __init__( + self, + *, + logs_dir: Path, + artifact_root: Path, + endpoints: dict[str, EndpointLaunchConfig], + buzz_acp_binary: str = "buzz-acp", + buzz_agent_binary: str = "buzz-agent", + buzz_dev_mcp_binary: str = "buzz-dev-mcp", + buzz_cli_binary: str = "buzz", + relay_gateway: str = "", + forwarder_binary: str = "relay-forwarder", + max_agent_rounds: int = DEFAULT_MAX_AGENT_ROUNDS, + readiness_timeout_seconds: float = 60.0, + poll_seconds: float = 1.0, + ) -> None: + if max_agent_rounds <= 0: + raise ValueError("max_agent_rounds must be positive") + if readiness_timeout_seconds <= 0: + raise ValueError("readiness_timeout_seconds must be positive") + self.logs_dir = Path(logs_dir) + self.artifact_root = Path(artifact_root) + self.endpoints = endpoints + # Linux builds uploaded into the task container: + self.buzz_acp_binary = buzz_acp_binary + self.buzz_agent_binary = buzz_agent_binary + self.buzz_dev_mcp_binary = buzz_dev_mcp_binary + # Host build used for user/provisioning operations only: + self.buzz_cli_binary = buzz_cli_binary + # Where the relay actually lives, as seen from inside the task + # container (e.g. host.docker.internal:3600). When set, a loopback + # forwarder bridges the agents' canonical relay address — the Host + # the relay's community row is bound to — to this gateway. + self.relay_gateway = relay_gateway + self.forwarder_binary = forwarder_binary + self.max_agent_rounds = max_agent_rounds + self.readiness_timeout_seconds = readiness_timeout_seconds + self.poll_seconds = poll_seconds + + async def run( + self, + *, + instruction: str, + environment: BaseEnvironment, + manifest: ExperimentManifest, + trial: TrialHandle, + ) -> RuntimeResult: + classes = self._classes_by_agent_id(manifest, trial.credentials) + orchestrator = next(c for c in trial.credentials if c.role == "orchestrator") + workers = [c for c in trial.credentials if c.agent_id != orchestrator.agent_id] + if not workers: + raise RuntimeLaunchError("Buzz orchestration requires at least one worker") + trial_dir = self.logs_dir / "buzz" + trial_dir.mkdir(parents=True, exist_ok=True) + + agents: list[_Agent] = [] + infra: list[_Agent] = [] + try: + await self._install_stack(environment) + forwarder = await self._start_forwarder(environment, trial) + if forwarder is not None: + infra.append(forwarder) + await self._buzz_json( + trial.user, trial, "users", "set-profile", "--name", + trial.user.agent_id, + ) + for credential in trial.credentials: + await self._buzz_json( + credential, trial, "users", "set-profile", "--name", + credential.agent_id, + ) + agents.append( + await self._launch_agent( + environment=environment, + trial=trial, + credential=credential, + agent_class=classes[credential.agent_id], + trial_dir=trial_dir, + ) + ) + await self._wait_for_agents_ready( + environment, agents, trial.channel_id, infra + ) + # The task arrives exactly as it would in production Buzz: a + # user prompt @mentioning the orchestrator. The harness never + # speaks as any agent. + await self._send( + trial.user, trial, f"@{orchestrator.agent_id} {instruction}" + ) + final_message = await asyncio.wait_for( + self._wait_for_done(environment, orchestrator, trial, agents + infra), + timeout=manifest.trial_budget.timeout_seconds, + ) + await self._verify_m1_output(environment, manifest) + finally: + await self._stop_agents(environment, agents + infra) + await self._collect_logs(environment, trial_dir) + + return RuntimeResult( + metadata={ + "completion_message_id": final_message["id"], + "completion_message": final_message["content"], + "agent_runtime": "in-container", + "agent_hints_enabled": False, + "task_seed": "user-identity-prompt", + "agent_max_rounds": { + credential.agent_id: ( + classes[credential.agent_id].budget.max_calls + or self.max_agent_rounds + ) + for credential in trial.credentials + }, + } + ) + + # -- container setup ------------------------------------------------------ + + async def _install_stack(self, environment: BaseEnvironment) -> None: + """Upload the pinned Linux binaries into the task container.""" + uploads = { + f"{REMOTE_BIN}/buzz-acp": self.buzz_acp_binary, + f"{REMOTE_BIN}/buzz-agent": self.buzz_agent_binary, + f"{REMOTE_BIN}/buzz-dev-mcp": self.buzz_dev_mcp_binary, + } + if self.relay_gateway: + uploads[FORWARDER] = self.forwarder_binary + for source in uploads.values(): + if not Path(source).is_file(): + raise RuntimeLaunchError(f"agent binary not found: {source}") + result = await environment.exec( + f"mkdir -p {REMOTE_BIN} {REMOTE_PROMPTS} {REMOTE_LOGS}" + ) + if result.return_code != 0: + raise RuntimeLaunchError( + f"cannot create {REMOTE_ROOT} in the task container: " + f"{result.stderr or result.stdout}" + ) + for target, source in uploads.items(): + await environment.upload_file(source, target) + await environment.exec(f"chmod 0755 {REMOTE_BIN}/*") + + async def _start_forwarder( + self, environment: BaseEnvironment, trial: TrialHandle + ) -> _Agent | None: + """Bridge the agents' canonical relay address to the real gateway. + + The relay resolves its tenant from the request ``Host`` header, so the + agents must dial the exact authority its community row is bound to — + ``trial.relay_ws_url``. When that address is loopback inside the task + container but the relay lives on the Docker host, this starts the + uploaded forwarder listening on the canonical address and pumping the + byte stream to ``relay_gateway``. Returns ``None`` when no gateway is + configured (the relay is reachable directly). + """ + if not self.relay_gateway: + return None + # Listen on the IPv4 loopback explicitly: binding the name `localhost` + # would pick whichever address family resolves first, while clients + # iterate both — pinning v4 makes the pair deterministic. The Host + # header the relay tenant-binds on comes from the URL the agents + # dial (trial.relay_ws_url), not from the socket address. + listen = self._ws_authority(trial.relay_ws_url).replace( + "localhost", "127.0.0.1", 1 + ) + log = FORWARDER_LOG + command = ( + f"{shlex.quote(FORWARDER)} {shlex.quote(listen)} " + f"{shlex.quote(self.relay_gateway)} {shlex.quote(log)} 2>&1 & echo $!" + ) + result = await environment.exec(command) + try: + pid = int((result.stdout or "").strip().splitlines()[-1]) + except (ValueError, IndexError) as error: + raise RuntimeLaunchError( + f"cannot launch relay forwarder: {result.stderr or result.stdout}" + ) from error + forwarder = _Agent( + AgentCredential( + agent_id="relay-forwarder", role="infra", + nostr_secret_key="", nostr_pubkey="", nostr_auth_tag="", + llm_endpoint="", llm_api_key="", + ), + pid, log, log, + ) + deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds + while True: + probe = await environment.exec(f"cat {shlex.quote(log)} 2>/dev/null") + if "forwarding" in (probe.stdout or ""): + return forwarder + await self._raise_for_dead_agents(environment, [forwarder]) + if asyncio.get_running_loop().time() >= deadline: + raise RuntimeLaunchError( + "relay forwarder did not report readiness; " + f"see {log} in the trial artifacts" + ) + await asyncio.sleep(self.poll_seconds) + + @staticmethod + def _ws_authority(relay_ws_url: str) -> str: + """``host:port`` from a ws:// URL — the forwarder's listen address.""" + if not relay_ws_url.startswith("ws://"): + raise RuntimeLaunchError( + "relay_gateway forwarding requires a ws:// relay_ws_url" + ) + authority = relay_ws_url.removeprefix("ws://").split("/", 1)[0] + if ":" not in authority: + authority += ":80" + return authority + + async def _launch_agent( + self, + *, + environment: BaseEnvironment, + trial: TrialHandle, + credential: AgentCredential, + agent_class: AgentClass, + trial_dir: Path, + ) -> _Agent: + if not credential.llm_endpoint: + raise RuntimeLaunchError("credential llm_endpoint must not be empty") + endpoint = self.endpoints.get(credential.llm_endpoint) + if endpoint is None: + raise RuntimeLaunchError( + f"no launch config for endpoint {credential.llm_endpoint!r}" + ) + self._reject_identity_overrides(endpoint) + prompt_path = self.artifact_root / agent_class.prompt.path + self._verify_artifact(prompt_path, agent_class.prompt.sha256) + composed = self._compose_system_prompt( + trial_dir=trial_dir, + trial=trial, + credential=credential, + persona_path=prompt_path, + ) + remote_prompt = f"{REMOTE_PROMPTS}/{credential.agent_id}.system-prompt.md" + await environment.upload_file(composed, remote_prompt) + + stdout_log = f"{REMOTE_LOGS}/{credential.agent_id}.stdout.log" + stderr_log = f"{REMOTE_LOGS}/{credential.agent_id}.stderr.log" + env = self._agent_env( + trial=trial, + credential=credential, + agent_class=agent_class, + endpoint=endpoint, + remote_prompt=remote_prompt, + ) + command = ( + f"{shlex.quote(f'{REMOTE_BIN}/buzz-acp')} {shlex.quote(stdout_log)} 2>{shlex.quote(stderr_log)} & echo $!" + ) + result = await environment.exec(command, env=env) + try: + pid = int((result.stdout or "").strip().splitlines()[-1]) + except (ValueError, IndexError) as error: + raise RuntimeLaunchError( + f"cannot launch agent {credential.agent_id}: " + f"{result.stderr or result.stdout}" + ) from error + return _Agent(credential, pid, stdout_log, stderr_log) + + def _agent_env( + self, + *, + trial: TrialHandle, + credential: AgentCredential, + agent_class: AgentClass, + endpoint: EndpointLaunchConfig, + remote_prompt: str, + ) -> dict[str, str]: + """The desktop-launch environment: real acp/agent/dev-mcp wiring.""" + return { + **endpoint.env, + "BUZZ_RELAY_URL": trial.relay_ws_url, + "BUZZ_PRIVATE_KEY": credential.nostr_secret_key, + # Desktop parity: the GUI also sets NOSTR_PRIVATE_KEY on buzz-acp + # so buzz-dev-mcp's shim can wire git auth/signing for the agent. + "NOSTR_PRIVATE_KEY": credential.nostr_secret_key, + "BUZZ_AUTH_TAG": credential.nostr_auth_tag, + "BUZZ_ACP_AGENT_COMMAND": f"{REMOTE_BIN}/buzz-agent", + "BUZZ_ACP_AGENT_ARGS": "", + "BUZZ_ACP_MCP_COMMAND": f"{REMOTE_BIN}/buzz-dev-mcp", + "BUZZ_ACP_CHANNELS": trial.channel_id, + "BUZZ_ACP_SUBSCRIBE": "mentions", + "BUZZ_ACP_RESPOND_TO": "anyone", + "BUZZ_ACP_NO_MEMORY": "true", + "BUZZ_ACP_SYSTEM_PROMPT_FILE": remote_prompt, + "BUZZ_AGENT_PROVIDER": endpoint.provider, + "BUZZ_AGENT_MODEL": credential.llm_endpoint, + "BUZZ_AGENT_MAX_OUTPUT_TOKENS": str( + agent_class.generation.max_output_tokens + ), + "BUZZ_AGENT_MAX_CONTEXT_TOKENS": str( + agent_class.generation.context_window_tokens + ), + "BUZZ_AGENT_MAX_ROUNDS": str( + agent_class.budget.max_calls or self.max_agent_rounds + ), + # The pinned persona is the whole prompt: no hint-file or skill + # discovery from the task filesystem (metadata reports this). + "BUZZ_AGENT_NO_HINTS": "1", + endpoint.api_key_env: credential.llm_api_key, + } + + # -- lifecycle ------------------------------------------------------------- + + async def _wait_for_agents_ready( + self, + environment: BaseEnvironment, + agents: list[_Agent], + channel_id: str, + infra: list[_Agent] | None = None, + ) -> None: + """Wait until every ACP process confirms its trial-channel subscription.""" + marker = f"subscribed to channel {channel_id}" + deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds + pending = {agent.credential.agent_id: agent for agent in agents} + while pending: + await self._raise_for_dead_agents(environment, agents + (infra or [])) + for agent_id, agent in list(pending.items()): + result = await environment.exec( + f"cat {shlex.quote(agent.stdout_log)} " + f"{shlex.quote(agent.stderr_log)} 2>/dev/null" + ) + if marker in (result.stdout or ""): + del pending[agent_id] + if not pending: + return + if asyncio.get_running_loop().time() >= deadline: + raise RuntimeLaunchError( + "agents did not subscribe to trial channel before readiness " + f"timeout: {sorted(pending)}" + ) + await asyncio.sleep(self.poll_seconds) + + async def _wait_for_done( + self, + environment: BaseEnvironment, + orchestrator: AgentCredential, + trial: TrialHandle, + agents: list[_Agent], + ) -> dict[str, Any]: + """Observe the channel as the trial user until the orchestrator posts DONE. + + Observation only: the harness never speaks as any agent. If the team + stalls, the trial times out and the stall is the measured result. + """ + polls = 0 + while True: + if polls % LIVENESS_EVERY == 0: + await self._raise_for_dead_agents(environment, agents) + polls += 1 + messages = await self._buzz_json( + trial.user, trial, + "messages", "get", "--channel", trial.channel_id, + "--limit", "100", + ) + for message in messages: + if message.get("pubkey") == orchestrator.nostr_pubkey and str( + message.get("content", "") + ).startswith("DONE:"): + return message + await asyncio.sleep(self.poll_seconds) + + async def _raise_for_dead_agents( + self, environment: BaseEnvironment, agents: list[_Agent] + ) -> None: + if not agents: + return + probes = "; ".join( + f"kill -0 {agent.pid} 2>/dev/null || echo DEAD:{agent.credential.agent_id}" + for agent in agents + ) + result = await environment.exec(probes) + dead = [ + line.removeprefix("DEAD:") + for line in (result.stdout or "").splitlines() + if line.startswith("DEAD:") + ] + if dead: + raise RuntimeLaunchError( + f"agent processes exited early: {sorted(dead)}; " + f"see {REMOTE_LOGS} in the trial artifacts" + ) + + @staticmethod + async def _stop_agents( + environment: BaseEnvironment, agents: list[_Agent] + ) -> None: + """Terminate every process of the uploaded stack (acp, agent, mcp).""" + if not agents: + return + # Match by cmdline prefix via /proc: pkill/procps is not guaranteed + # to exist in task images, the /proc filesystem is. + sweep = ( + "for d in /proc/[0-9]*; do " + f"grep -aq {REMOTE_BIN} \"$d/cmdline\" 2>/dev/null " + "&& kill -TERM \"${d#/proc/}\" 2>/dev/null; done; true" + ) + try: + await environment.exec(sweep) + await asyncio.sleep(2) + await environment.exec(sweep.replace("-TERM", "-KILL")) + except Exception: # noqa: BLE001 — environment may already be gone + pass + + async def _collect_logs( + self, environment: BaseEnvironment, trial_dir: Path + ) -> None: + try: + await environment.download_dir(REMOTE_LOGS, trial_dir) + except Exception: # noqa: BLE001 — best effort; env may be torn down + pass + + # -- Buzz CLI as the trial user / provisioning identities ------------------- + + @staticmethod + async def _verify_m1_output( + environment: BaseEnvironment, manifest: ExperimentManifest + ) -> None: + """Fail M1 immediately unless the artifact satisfies the grader contract.""" + if manifest.condition != "M1-hello-world": + return + result = await environment.exec( + 'python3 -c "from pathlib import Path; ' + "p = Path('/app/hello.txt'); " + "assert p.is_file() and p.read_text().strip() == 'Hello, world!'\"" + ) + if result.return_code != 0: + detail = ( + result.stderr or result.stdout or "grader-equivalent check failed" + ).strip() + raise RuntimeLaunchError( + "M1 pre-verifier sanity probe failed: /app/hello.txt must exist " + f"and its stripped text must equal 'Hello, world!' ({detail})" + ) + + async def _send( + self, credential: AgentCredential, trial: TrialHandle, content: str + ) -> None: + await self._buzz_json( + credential, trial, + "messages", "send", "--channel", trial.channel_id, + "--content", content, + ) + + async def _buzz_json( + self, credential: AgentCredential, trial: TrialHandle, *args: str + ) -> Any: + process = await asyncio.create_subprocess_exec( + self.buzz_cli_binary, + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={ + **os.environ, + "BUZZ_RELAY_URL": self._user_relay_url(trial), + "BUZZ_PRIVATE_KEY": credential.nostr_secret_key, + "BUZZ_AUTH_TAG": credential.nostr_auth_tag, + }, + ) + stdout, stderr = await process.communicate() + if process.returncode != 0: + raise RuntimeLaunchError( + f"buzz {shlex.join(args)} exited {process.returncode}: " + f"{stderr.decode(errors='replace').strip()}" + ) + try: + return json.loads(stdout) + except json.JSONDecodeError as error: + raise RuntimeLaunchError("buzz returned invalid JSON") from error + + @staticmethod + def _user_relay_url(trial: TrialHandle) -> str: + """The relay as reachable from the HOST (user identity, harness). + + ``trial.relay_ws_url`` is the container view (the agents' runtime); + ``trial.user_relay_url`` is the host view. Fall back to deriving an + http URL from the ws URL for handles minted before v1.2. + """ + if trial.user_relay_url: + return trial.user_relay_url + return BuzzContainerRuntime._cli_relay_url(trial.relay_ws_url) + + @staticmethod + def _cli_relay_url(relay_ws_url: str) -> str: + if relay_ws_url.startswith("ws://"): + return f"http://{relay_ws_url.removeprefix('ws://')}" + if relay_ws_url.startswith("wss://"): + return f"https://{relay_ws_url.removeprefix('wss://')}" + raise RuntimeLaunchError("trial relay_ws_url must use ws:// or wss://") + + # -- manifest plumbing ------------------------------------------------------- + + @staticmethod + def _classes_by_agent_id( + manifest: ExperimentManifest, credentials: tuple[AgentCredential, ...] + ) -> dict[str, AgentClass]: + by_id = {entry.id: entry for entry in manifest.roster} + result: dict[str, AgentClass] = {} + for credential in credentials: + class_id, separator, index = credential.agent_id.rpartition("-") + match = by_id.get(class_id) + if not separator or not index.isdigit() or match is None: + raise RuntimeLaunchError( + f"credential {credential.agent_id!r} does not match a roster class" + ) + if credential.role != match.kind: + raise RuntimeLaunchError( + f"credential {credential.agent_id!r} role does not match manifest" + ) + result[credential.agent_id] = match + return result + + @staticmethod + def _verify_artifact(path: Path, expected_sha256: str) -> None: + import hashlib + + try: + actual = hashlib.sha256(path.read_bytes()).hexdigest() + except OSError as error: + raise RuntimeLaunchError(f"cannot read prompt {path}: {error}") from error + if actual != expected_sha256: + raise RuntimeLaunchError( + f"prompt hash mismatch for {path}: expected {expected_sha256}, got {actual}" + ) + + def _compose_system_prompt( + self, + *, + trial_dir: Path, + trial: TrialHandle, + credential: AgentCredential, + persona_path: Path, + ) -> Path: + """Append the trial's team roster to the pinned persona. + + The analogue of a production Buzz workspace's team context: each agent + knows its own identity, its channel, the user it reports to, and its + teammates' names, pubkeys, and roles from its system prompt — it never + has to discover them over the relay. + """ + persona = persona_path.read_text(encoding="utf-8") + lines = [ + "", + "## Your team", + "", + f"You are `{credential.agent_id}` (pubkey `{credential.nostr_pubkey}`).", + f"The team coordinates in Buzz channel `{trial.channel_id}`.", + f"Tasks come from the user `{trial.user.agent_id}` " + f"(pubkey `{trial.user.nostr_pubkey}`); address your final report " + "to them.", + "", + "| Name | Role | Pubkey |", + "|------|------|--------|", + ] + for teammate in trial.credentials: + if teammate.agent_id == credential.agent_id: + continue + lines.append( + f"| {teammate.agent_id} | {teammate.role} " + f"| `{teammate.nostr_pubkey}` |" + ) + composed = persona + "\n".join(lines) + "\n" + path = trial_dir / f"{credential.agent_id}.system-prompt.md" + path.write_text(composed, encoding="utf-8") + path.chmod(0o600) + return path + + @staticmethod + def _reject_identity_overrides(endpoint: EndpointLaunchConfig) -> None: + forbidden = { + "BUZZ_RELAY_URL", + "BUZZ_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_ACP_CHANNELS", + "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_AGENT_COMMAND", + } + overlap = forbidden & endpoint.env.keys() + if overlap: + raise RuntimeLaunchError( + f"endpoint env cannot override trial identity: {sorted(overlap)}" + ) diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/manifest.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/manifest.py new file mode 100644 index 0000000000..309c0a5770 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/manifest.py @@ -0,0 +1,145 @@ +"""Validated, content-addressed experiment manifests.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any, Literal, Self + +import yaml +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class ManifestError(ValueError): + """Raised when a manifest cannot be loaded or validated.""" + + +class StrictModel(BaseModel): + """Base model that rejects misspelled or unrecognised manifest fields.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class ArtifactRef(StrictModel): + """Reference to immutable prompt, persona, or skill content.""" + + path: str = Field(min_length=1) + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class GenerationConfig(StrictModel): + """Model generation controls frozen for a condition.""" + + temperature: float = Field(default=0.0, ge=0.0) + max_output_tokens: int = Field(gt=0) + context_window_tokens: int = Field(gt=0) + extra: dict[str, Any] = Field(default_factory=dict) + + +class AgentBudget(StrictModel): + """Optional per-agent live safety limits.""" + + max_calls: int | None = Field(default=None, gt=0) + max_input_tokens: int | None = Field(default=None, gt=0) + max_output_tokens: int | None = Field(default=None, gt=0) + max_cost_usd: float | None = Field(default=None, gt=0) + + +class Price(StrictModel): + """Frozen USD rates for one endpoint revision, per million tokens.""" + + input_per_million_usd: float = Field(ge=0) + cached_input_per_million_usd: float = Field(ge=0) + output_per_million_usd: float = Field(ge=0) + + +class AgentClass(StrictModel): + """A homogeneous class of agents in the trial roster.""" + + id: str = Field(min_length=1, pattern=r"^[a-z0-9][a-z0-9._-]*$") + kind: Literal["orchestrator", "worker"] + role: str = Field(min_length=1) + count: int = Field(gt=0) + endpoint: str = Field(min_length=1) + model_revision: str = Field(min_length=1) + prompt: ArtifactRef + persona: ArtifactRef | None = None + skills: tuple[ArtifactRef, ...] = () + generation: GenerationConfig + budget: AgentBudget = AgentBudget() + concurrency: int = Field(default=1, gt=0) + + @model_validator(mode="after") + def validate_concurrency(self) -> Self: + if self.concurrency > self.count: + raise ValueError("concurrency cannot exceed count") + return self + + +class TrialBudget(StrictModel): + """Trial-wide hard limits enforced by the live runtime, not async receipts.""" + + timeout_seconds: int = Field(gt=0) + max_cost_usd: float | None = Field(default=None, gt=0) + + +class ExperimentManifest(StrictModel): + """Complete immutable input defining one benchmark condition.""" + + schema_version: Literal["1"] = "1" + condition: str = Field(min_length=1) + roster: tuple[AgentClass, ...] = Field(min_length=1) + prices: dict[str, Price] + trial_budget: TrialBudget + metadata: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_roster(self) -> Self: + ids = [entry.id for entry in self.roster] + if len(ids) != len(set(ids)): + raise ValueError("roster class ids must be unique") + orchestrators = sum( + entry.count for entry in self.roster if entry.kind == "orchestrator" + ) + if orchestrators != 1: + raise ValueError("roster must contain exactly one orchestrator") + endpoints = {entry.endpoint for entry in self.roster} + missing_prices = sorted(endpoints - self.prices.keys()) + if missing_prices: + raise ValueError(f"prices missing for endpoints: {missing_prices}") + return self + + def canonical_bytes(self) -> bytes: + """Return stable UTF-8 JSON independent of YAML formatting and key order.""" + data = self.model_dump(mode="json", exclude_none=False) + return json.dumps( + data, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + @property + def sha256(self) -> str: + """Return the condition identity derived from canonical manifest content.""" + return hashlib.sha256(self.canonical_bytes()).hexdigest() + + @classmethod + def load(cls, source: str | Path | dict[str, Any]) -> Self: + """Load a manifest from a YAML/JSON file or an already-decoded mapping.""" + if isinstance(source, dict): + raw = source + else: + path = Path(source).expanduser() + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as error: + raise ManifestError(f"cannot load manifest {path}: {error}") from error + if not isinstance(raw, dict): + raise ManifestError("manifest root must be a mapping") + try: + return cls.model_validate(raw) + except ValueError as error: + raise ManifestError(f"invalid manifest: {error}") from error diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py new file mode 100644 index 0000000000..37e9bbe478 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py @@ -0,0 +1,58 @@ +"""Typed boundary between the Harbor adapter and Buzz trial provisioning.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from .manifest import ExperimentManifest + + +@dataclass(frozen=True, slots=True) +class AgentCredential: + """One trial-scoped Buzz identity and attributed LLM credential.""" + + agent_id: str + role: str + nostr_secret_key: str + nostr_pubkey: str + nostr_auth_tag: str + llm_endpoint: str + llm_api_key: str + + +@dataclass(frozen=True, slots=True) +class TrialHandle: + """Provisioned Buzz resources owned by one Harbor trial.""" + + run_id: str + trial_id: str + manifest_hash: str + relay_ws_url: str + channel_id: str + credentials: tuple[AgentCredential, ...] + # The trial's human-analogue identity: owns the channel, posts the task + # as a user prompt, and receives the final report. Never runs an agent + # process, so its llm_endpoint and llm_api_key are empty strings. + user: AgentCredential + # v1.2 (additive): the relay as reachable from the HOST, where the user + # identity and the harness run. ``relay_ws_url`` is the view from the + # agents' runtime (the task container). Empty means both views coincide. + user_relay_url: str = "" + + +@runtime_checkable +class TrialProvisioner(Protocol): + """Creates and tears down trial-isolated Buzz resources synchronously.""" + + def create_trial( + self, + run_id: str, + trial_id: str, + manifest: ExperimentManifest, + channel_label: str | None = None, + ) -> TrialHandle: ... + + def teardown(self, handle: TrialHandle) -> None: ... + + def healthcheck(self) -> None: ... diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/runtime.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/runtime.py new file mode 100644 index 0000000000..d370824797 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/runtime.py @@ -0,0 +1,35 @@ +"""Runtime contract kept separate from Buzz resource provisioning.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol + +from harbor.environments.base import BaseEnvironment + +from .manifest import ExperimentManifest +from .provisioning import TrialHandle + + +@dataclass(frozen=True, slots=True) +class RuntimeResult: + """Aggregate values returned by an orchestration runtime.""" + + input_tokens: int = 0 + cached_input_tokens: int = 0 + output_tokens: int = 0 + cost_usd: float = 0.0 + metadata: dict[str, Any] = field(default_factory=dict) + + +class OrchestraRuntime(Protocol): + """Runs the provisioned roster and writes trial artifacts into logs_dir.""" + + async def run( + self, + *, + instruction: str, + environment: BaseEnvironment, + manifest: ExperimentManifest, + trial: TrialHandle, + ) -> RuntimeResult: ... diff --git a/benchmarks/harbor-buzz-orchestra/testbed/compose.benchmark.yml b/benchmarks/harbor-buzz-orchestra/testbed/compose.benchmark.yml new file mode 100644 index 0000000000..e9949fca70 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/compose.benchmark.yml @@ -0,0 +1,18 @@ +# Benchmark-only overrides for deploy/compose/compose.yml. +# +# The production bundle stays untouched; this file publishes what the +# benchmark harness needs on the host: +# - relay Prometheus metrics (:9102) for scraping during runs +# - Postgres (:5433) for the provisioner and receipts ingestion +# (5433 to avoid colliding with other local Postgres instances) +# +# Usage: +# docker compose --env-file .env \ +# -f compose.yml -f up -d --wait +services: + relay: + ports: + - "${BUZZ_METRICS_HOST_PORT:-9102}:9102" + postgres: + ports: + - "${BUZZ_PG_HOST_PORT:-5433}:5432" diff --git a/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md new file mode 100644 index 0000000000..f8d2a560bf --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/README.md @@ -0,0 +1,26 @@ +# Endpoint launch configs + +Deployment-time mapping from manifest endpoint names to +`EndpointLaunchConfig` (`provider` / `api_key_env` / `env`), passed to +`harbor run` as `--agent-kwarg endpoint_config=`. This is deployment +config, deliberately OUTSIDE the immutable condition manifest — the manifest +endpoint string remains the join key. + +Every key in these files must be a manifest endpoint name; the loader treats +all entries as endpoint configs (no comment keys). + +## m1-local.json + +M1 wiring proof: both placeholder endpoints resolve to one local llama-server +(OpenAI-compatible, `http://127.0.0.1:8091/v1`, no cloud keys). + +buzz-agent env contract (crates/buzz-agent/src/config.rs, pinned at the M1 +binary SHA): `provider=openai` reads `OPENAI_COMPAT_API_KEY` + +`OPENAI_COMPAT_BASE_URL`; the runtime sets `BUZZ_AGENT_MODEL` from the +manifest endpoint name, which overrides `OPENAI_COMPAT_MODEL` — llama-server +ignores the model name, so the placeholder value is harmless there. +llama-server needs no real key; the provisioner's per-endpoint +`llm_api_keys` map supplies a dummy value. + +The Databricks pilot config is the same file shape with real serving +endpoint hosts/keys. diff --git a/benchmarks/harbor-buzz-orchestra/testbed/endpoints/anthropic-live.json b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/anthropic-live.json new file mode 100644 index 0000000000..4ec5fd671c --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/anthropic-live.json @@ -0,0 +1,12 @@ +{ + "claude-sonnet-4-6": { + "provider": "anthropic", + "api_key_env": "ANTHROPIC_API_KEY", + "env": {} + }, + "claude-haiku-4-5": { + "provider": "anthropic", + "api_key_env": "ANTHROPIC_API_KEY", + "env": {} + } +} diff --git a/benchmarks/harbor-buzz-orchestra/testbed/endpoints/m1-local.json b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/m1-local.json new file mode 100644 index 0000000000..dfd82f4e5f --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/m1-local.json @@ -0,0 +1,16 @@ +{ + "local/placeholder-orchestrator": { + "provider": "openai", + "api_key_env": "OPENAI_COMPAT_API_KEY", + "env": { + "OPENAI_COMPAT_BASE_URL": "http://127.0.0.1:8091/v1" + } + }, + "local/placeholder-worker": { + "provider": "openai", + "api_key_env": "OPENAI_COMPAT_API_KEY", + "env": { + "OPENAI_COMPAT_BASE_URL": "http://127.0.0.1:8091/v1" + } + } +} diff --git a/benchmarks/harbor-buzz-orchestra/testbed/pyproject.toml b/benchmarks/harbor-buzz-orchestra/testbed/pyproject.toml new file mode 100644 index 0000000000..934e7845ff --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "harbor-buzz-testbed" +version = "0.1.0" +description = "Trial provisioning and local Buzz stack tooling for harbor-buzz-orchestra" +requires-python = ">=3.12" +dependencies = [ + "harbor-buzz-orchestra", + "coincurve>=20", + "psycopg[binary]>=3.2", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project.optional-dependencies] +dev = [ + "pytest>=8.4", + "ruff>=0.15", +] + +[tool.uv.sources] +harbor-buzz-orchestra = { path = "..", editable = true } + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py312" +line-length = 88 diff --git a/benchmarks/harbor-buzz-orchestra/testbed/sql/benchmark_schema.sql b/benchmarks/harbor-buzz-orchestra/testbed/sql/benchmark_schema.sql new file mode 100644 index 0000000000..b0162d2347 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/sql/benchmark_schema.sql @@ -0,0 +1,72 @@ +-- Benchmark schema for harbor-buzz-orchestra runs. +-- +-- Lives in the shared Postgres instance but is OWNED BY THE HARNESS, never by +-- Buzz migrations (canonical plan §two-domain rule). Idempotent: safe to apply +-- on every testbed bring-up. +-- +-- docker exec -i psql -U buzz -d buzz < sql/benchmark_schema.sql + +CREATE SCHEMA IF NOT EXISTS benchmark; + +-- One row per provisioned trial; written by BuzzTrialProvisioner. +CREATE TABLE IF NOT EXISTS benchmark.trial_manifest ( + run_id text NOT NULL, + trial_id uuid NOT NULL, + manifest_hash text NOT NULL, + channel_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + handle jsonb NOT NULL DEFAULT '{}'::jsonb, + archived_at timestamptz, + PRIMARY KEY (run_id, trial_id) +); + +-- Immutable LLM receipts, ingested post-run from the accounting path +-- (Databricks AI Gateway inference tables first; LiteLLM shim fallback). +-- Authoritative for tokens/cost and orchestrator-vs-worker attribution; +-- Harbor AgentContext totals are a reconciliation checksum only. +CREATE TABLE IF NOT EXISTS benchmark.llm_receipts ( + receipt_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + run_id text NOT NULL, + trial_id uuid NOT NULL, + agent_id text NOT NULL, + role text NOT NULL, + condition text NOT NULL, + endpoint text NOT NULL, + model_revision text NOT NULL, + request_id text NOT NULL, -- gateway request identity + requested_at timestamptz NOT NULL, + latency_ms integer, + input_tokens bigint NOT NULL DEFAULT 0, + cached_input_tokens bigint NOT NULL DEFAULT 0, + output_tokens bigint NOT NULL DEFAULT 0, + cost_usd numeric(12, 6), + source text NOT NULL, -- 'ai_gateway' | 'litellm' | 'client' + raw jsonb NOT NULL DEFAULT '{}'::jsonb, + UNIQUE (source, request_id), + FOREIGN KEY (run_id, trial_id) + REFERENCES benchmark.trial_manifest (run_id, trial_id) +); + +CREATE INDEX IF NOT EXISTS llm_receipts_trial_idx + ON benchmark.llm_receipts (run_id, trial_id, agent_id); + +-- Harness-recorded timing spans (monotonic clocks are authoritative for +-- latency). kind examples: 'trial', 'llm_call', 'terminal_exec', +-- 'terminal_queue_wait' — queue-wait is recorded separately from execution so +-- speed can be reported both as-run and queue-adjusted under the M1 +-- serialized-broker policy. +CREATE TABLE IF NOT EXISTS benchmark.spans ( + span_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + run_id text NOT NULL, + trial_id uuid NOT NULL, + agent_id text, -- NULL for trial-level spans + kind text NOT NULL, + started_at timestamptz NOT NULL, + duration_ms bigint NOT NULL CHECK (duration_ms >= 0), + detail jsonb NOT NULL DEFAULT '{}'::jsonb, + FOREIGN KEY (run_id, trial_id) + REFERENCES benchmark.trial_manifest (run_id, trial_id) +); + +CREATE INDEX IF NOT EXISTS spans_trial_kind_idx + ON benchmark.spans (run_id, trial_id, kind); diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/__init__.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/__init__.py new file mode 100644 index 0000000000..8e873ea0a8 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/__init__.py @@ -0,0 +1,15 @@ +"""Testbed-side provisioning for harbor-buzz-orchestra trials.""" + +from .provisioner import ( + BuzzTrialProvisioner, + ProvisioningError, + TestbedConfig, + provisioner_from_dict, +) + +__all__ = [ + "BuzzTrialProvisioner", + "ProvisioningError", + "TestbedConfig", + "provisioner_from_dict", +] diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py new file mode 100644 index 0000000000..ed2bb31cc9 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py @@ -0,0 +1,102 @@ +"""Thin subprocess wrapper over the ``buzz`` CLI — the production client path.""" + +from __future__ import annotations + +import json +import subprocess +from typing import Any + + +class BuzzCliError(RuntimeError): + """A buzz CLI invocation failed.""" + + +class BuzzCli: + """Run buzz CLI commands as one relay identity (key + NIP-OA auth tag).""" + + def __init__( + self, + relay_url: str, + secret_key: str, + auth_tag: str, + *, + binary: str = "buzz", + timeout_seconds: float = 30.0, + ) -> None: + self._relay_url = relay_url + self._secret_key = secret_key + self._auth_tag = auth_tag + self._binary = binary + self._timeout = timeout_seconds + + def run(self, *args: str) -> Any: + """Run a buzz subcommand and return its parsed JSON stdout.""" + command = [self._binary, *args] + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=self._timeout, + env={ + "BUZZ_RELAY_URL": self._relay_url, + "BUZZ_PRIVATE_KEY": self._secret_key, + "BUZZ_AUTH_TAG": self._auth_tag, + "PATH": _path(), + }, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise BuzzCliError(f"buzz {args[0]}: {error}") from error + if completed.returncode != 0: + raise BuzzCliError( + f"buzz {' '.join(args)} exited {completed.returncode}: " + f"{completed.stderr.strip() or completed.stdout.strip()}" + ) + if not completed.stdout.strip(): + return None + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as error: + raise BuzzCliError( + f"buzz {args[0]} returned non-JSON output: {completed.stdout[:200]!r}" + ) from error + + def create_private_channel(self, name: str, description: str) -> str: + """Create a private stream channel; return its UUID.""" + response = self.run( + "channels", + "create", + "--name", + name, + "--type", + "stream", + "--visibility", + "private", + "--description", + description, + ) + channel_id = response.get("channel_id") if isinstance(response, dict) else None + if not channel_id: + raise BuzzCliError(f"channel create returned no channel_id: {response}") + return channel_id + + def add_member(self, channel_id: str, pubkey: str) -> None: + self.run( + "channels", + "add-member", + "--channel", + channel_id, + "--pubkey", + pubkey, + "--role", + "member", + ) + + def archive_channel(self, channel_id: str) -> None: + self.run("channels", "archive", "--channel", channel_id) + + +def _path() -> str: + import os + + return os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin") diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/keys.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/keys.py new file mode 100644 index 0000000000..46b04af8db --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/keys.py @@ -0,0 +1,90 @@ +"""Nostr keygen and NIP-OA owner attestation for trial agents.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass + +import coincurve + + +@dataclass(frozen=True, slots=True) +class NostrKeypair: + secret_key: str # hex + pubkey: str # x-only hex + + +def generate_keypair() -> NostrKeypair: + """Generate a fresh secp256k1 keypair in Nostr hex form.""" + key = coincurve.PrivateKey() + return NostrKeypair( + secret_key=key.to_hex(), + pubkey=key.public_key_xonly.format().hex(), + ) + + +def keypair_from_secret(secret_key: str) -> NostrKeypair: + """Rebuild the keypair for an existing hex secret key.""" + key = coincurve.PrivateKey(bytes.fromhex(secret_key)) + return NostrKeypair( + secret_key=secret_key, + pubkey=key.public_key_xonly.format().hex(), + ) + + +_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def _bech32_polymod(values: list[int]) -> int: + generator = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3) + checksum = 1 + for value in values: + top = checksum >> 25 + checksum = (checksum & 0x1FFFFFF) << 5 ^ value + for i in range(5): + checksum ^= generator[i] if (top >> i) & 1 else 0 + return checksum + + +def encode_nsec(secret_key: str) -> str: + """Encode a hex secret key as a NIP-19 bech32 ``nsec1…`` string — + the form the desktop GUI's key-import onboarding accepts.""" + hrp = "nsec" + data: list[int] = [] + accumulator = bits = 0 + for byte in bytes.fromhex(secret_key): + accumulator = accumulator << 8 | byte + bits += 8 + while bits >= 5: + bits -= 5 + data.append(accumulator >> bits & 31) + if bits: + data.append(accumulator << (5 - bits) & 31) + expanded = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + polymod = _bech32_polymod(expanded + data + [0] * 6) ^ 1 + checksum = [polymod >> 5 * (5 - i) & 31 for i in range(6)] + return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in data + checksum) + + +def compute_auth_tag( + owner_secret_key: str, agent_pubkey: str, conditions: str = "" +) -> str: + """Compute the NIP-OA ``["auth", ...]`` tag authorising an agent key. + + Mirrors crates/buzz-sdk/src/nip_oa.rs: + sig = schnorr(SHA256("nostr:agent-auth:" || agent_pubkey || ":" || conditions), + owner_secret_key). Returns the tag as a JSON string. + """ + owner = coincurve.PrivateKey(bytes.fromhex(owner_secret_key)) + preimage = f"nostr:agent-auth:{agent_pubkey}:{conditions}".encode() + signature = owner.sign_schnorr(hashlib.sha256(preimage).digest()) + return json.dumps( + [ + "auth", + owner.public_key_xonly.format().hex(), + conditions, + signature.hex(), + ], + separators=(",", ":"), + ) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py new file mode 100644 index 0000000000..cfda6b59fa --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py @@ -0,0 +1,274 @@ +"""Channel-per-trial Buzz provisioning against a local benchmark stack.""" + +from __future__ import annotations + +import dataclasses +import hashlib +import json +import urllib.error +import urllib.request +from dataclasses import dataclass + +import psycopg +from harbor_buzz_orchestra.manifest import ExperimentManifest +from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle + +from .buzz_cli import BuzzCli +from .keys import compute_auth_tag, generate_keypair, keypair_from_secret + + +class ProvisioningError(RuntimeError): + """Trial provisioning failed or was invoked inconsistently.""" + + +@dataclass(frozen=True, slots=True) +class TestbedConfig: + """Connection settings for one local benchmark stack.""" + + __test__ = False # not a pytest class, despite the name + relay_http_url: str # e.g. http://localhost:3000 — CLI + healthcheck + relay_ws_url: str # as reachable FROM the agents' runtime + owner_secret_key: str # relay owner key; signs NIP-OA attestations + postgres_dsn: str # benchmark schema lives here + llm_api_keys: dict[str, str] = dataclasses.field(default_factory=dict) + # endpoint -> API key. v1: per-endpoint resolution; true per-agent + # Databricks attribution is pending the AI Gateway field verification. + # Pinned user identity (hex secret key). When set, every trial's user — + # the human analogue that owns the channel and posts the task — is this + # one identity instead of a fresh key per trial, so a GUI logged in as + # that user sees every trial channel accumulate. None preserves the + # fresh-user-per-trial behaviour. + user_secret_key: str | None = None + # When False, teardown leaves the trial channel unarchived (and its + # archived_at stamp NULL) so finished trials stay visible in a GUI. + archive_on_teardown: bool = True + + +def provisioner_from_dict(config: dict[str, object]) -> "BuzzTrialProvisioner": + """Harbor CLI factory for a JSON-decoded testbed configuration.""" + return BuzzTrialProvisioner(TestbedConfig(**config)) + + +class BuzzTrialProvisioner: + """Implements the TrialHandle v1.1 contract against a live Buzz relay. + + Guarantees (contract PLANS/HARBOR_BUZZ_TRIALHANDLE_CONTRACT.md): + - create_trial is synchronous and idempotent per (run_id, trial_id); + concurrency-safe via a Postgres advisory lock on the trial key. + - One private channel per trial; membership is exactly the trial's + credentials, so cross-trial reads are blocked by construction. + - Fresh keys per trial; never reused. + - teardown archives the channel and stamps archived_at; events are + never deleted. + """ + + def __init__(self, config: TestbedConfig) -> None: + self._config = config + + # -- contract surface --------------------------------------------------- + + def create_trial( + self, + run_id: str, + trial_id: str, + manifest: ExperimentManifest, + channel_label: str | None = None, + ) -> TrialHandle: + manifest_hash = manifest.sha256 + with psycopg.connect(self._config.postgres_dsn) as conn: + self._lock_trial(conn, run_id, trial_id) + existing = self._load_trial(conn, run_id, trial_id) + if existing is not None: + if existing.manifest_hash != manifest_hash: + raise ProvisioningError( + f"trial ({run_id}, {trial_id}) already provisioned with " + f"manifest {existing.manifest_hash}, got {manifest_hash}" + ) + return existing + + handle = self._provision( + run_id, trial_id, manifest, manifest_hash, channel_label + ) + self._store_trial(conn, handle) + conn.commit() + return handle + + def teardown(self, handle: TrialHandle) -> None: + if not self._config.archive_on_teardown: + # GUI/spectator mode: finished trial channels stay visible. + return + cli = self._cli_for(handle.credentials[0]) + try: + cli.archive_channel(handle.channel_id) + except Exception as error: # noqa: BLE001 — idempotent re-teardown + if "archived" not in str(error).lower(): + raise + with psycopg.connect(self._config.postgres_dsn) as conn: + conn.execute( + "UPDATE benchmark.trial_manifest" + " SET archived_at = COALESCE(archived_at, now())" + " WHERE run_id = %s AND trial_id = %s", + (handle.run_id, handle.trial_id), + ) + conn.commit() + + def healthcheck(self) -> None: + url = f"{self._config.relay_http_url.rstrip('/')}/_readiness" + try: + with urllib.request.urlopen(url, timeout=5) as response: + if response.status != 200: + raise ProvisioningError( + f"relay readiness returned {response.status}" + ) + except (urllib.error.URLError, OSError) as error: + raise ProvisioningError(f"relay unreachable at {url}: {error}") from error + try: + with psycopg.connect(self._config.postgres_dsn, connect_timeout=5) as conn: + conn.execute("SELECT 1") + except psycopg.Error as error: + raise ProvisioningError( + f"benchmark postgres unreachable: {error}" + ) from error + + # -- internals ---------------------------------------------------------- + + def _provision( + self, + run_id: str, + trial_id: str, + manifest: ExperimentManifest, + manifest_hash: str, + channel_label: str | None, + ) -> TrialHandle: + credentials = self._mint_credentials(manifest) + user = self._mint_user() + # The user identity creates the channel and invites the agents — + # mirroring production Buzz, where a human owns the channel their + # agents work in. + cli = self._cli_for(user) + name = ( + f"{channel_label}-{trial_id[:8]}" + if channel_label + else f"trial-{trial_id[:8]}-{manifest_hash[:8]}" + ) + channel_id = cli.create_private_channel( + name=name, + description=f"run={run_id} trial={trial_id} manifest={manifest_hash}", + ) + for credential in credentials: + cli.add_member(channel_id, credential.nostr_pubkey) + return TrialHandle( + run_id=run_id, + trial_id=trial_id, + manifest_hash=manifest_hash, + relay_ws_url=self._config.relay_ws_url, + channel_id=channel_id, + credentials=credentials, + user=user, + user_relay_url=self._config.relay_http_url, + ) + + def _mint_credentials( + self, manifest: ExperimentManifest + ) -> tuple[AgentCredential, ...]: + roster = sorted(manifest.roster, key=lambda e: e.kind != "orchestrator") + credentials: list[AgentCredential] = [] + for entry in roster: + api_key = self._config.llm_api_keys.get(entry.endpoint) + if api_key is None: + raise ProvisioningError( + f"no LLM API key configured for endpoint {entry.endpoint!r}" + ) + for index in range(1, entry.count + 1): + keypair = generate_keypair() + credentials.append( + AgentCredential( + agent_id=f"{entry.id}-{index}", + role=entry.kind, + nostr_secret_key=keypair.secret_key, + nostr_pubkey=keypair.pubkey, + nostr_auth_tag=compute_auth_tag( + self._config.owner_secret_key, keypair.pubkey + ), + llm_endpoint=entry.endpoint, + llm_api_key=api_key, + ) + ) + return tuple(credentials) + + def _mint_user(self) -> AgentCredential: + """Mint the trial's user identity — the human analogue, not an agent. + + With a pinned ``user_secret_key`` the same identity fronts every + trial, like one human running many teams; otherwise each trial gets + a fresh user key. + """ + keypair = ( + keypair_from_secret(self._config.user_secret_key) + if self._config.user_secret_key + else generate_keypair() + ) + return AgentCredential( + agent_id="user", + role="user", + nostr_secret_key=keypair.secret_key, + nostr_pubkey=keypair.pubkey, + nostr_auth_tag=compute_auth_tag( + self._config.owner_secret_key, keypair.pubkey + ), + llm_endpoint="", + llm_api_key="", + ) + + def _cli_for(self, credential: AgentCredential) -> BuzzCli: + return BuzzCli( + relay_url=self._config.relay_http_url, + secret_key=credential.nostr_secret_key, + auth_tag=credential.nostr_auth_tag, + ) + + @staticmethod + def _lock_trial(conn: psycopg.Connection, run_id: str, trial_id: str) -> None: + digest = hashlib.sha256(f"{run_id}\x00{trial_id}".encode()).digest() + lock_key = int.from_bytes(digest[:8], "big", signed=True) + conn.execute("SELECT pg_advisory_xact_lock(%s)", (lock_key,)) + + @staticmethod + def _load_trial( + conn: psycopg.Connection, run_id: str, trial_id: str + ) -> TrialHandle | None: + row = conn.execute( + "SELECT handle FROM benchmark.trial_manifest" + " WHERE run_id = %s AND trial_id = %s", + (run_id, trial_id), + ).fetchone() + if row is None: + return None + stored = row[0] + return TrialHandle( + run_id=stored["run_id"], + trial_id=stored["trial_id"], + manifest_hash=stored["manifest_hash"], + relay_ws_url=stored["relay_ws_url"], + channel_id=stored["channel_id"], + credentials=tuple( + AgentCredential(**credential) for credential in stored["credentials"] + ), + user=AgentCredential(**stored["user"]), + user_relay_url=stored.get("user_relay_url", ""), + ) + + @staticmethod + def _store_trial(conn: psycopg.Connection, handle: TrialHandle) -> None: + conn.execute( + "INSERT INTO benchmark.trial_manifest" + " (run_id, trial_id, manifest_hash, channel_id, handle)" + " VALUES (%s, %s, %s, %s, %s)", + ( + handle.run_id, + handle.trial_id, + handle.manifest_hash, + handle.channel_id, + json.dumps(dataclasses.asdict(handle)), + ), + ) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/conftest.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/conftest.py new file mode 100644 index 0000000000..f2f99c937f --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/conftest.py @@ -0,0 +1,59 @@ +"""Shared fixtures for testbed provisioning tests.""" + +from __future__ import annotations + +import pytest +from harbor_buzz_orchestra.manifest import ExperimentManifest + +PROMPT_REF = {"path": "personas/x.md", "sha256": "0" * 64} + + +@pytest.fixture() +def manifest() -> ExperimentManifest: + """A minimal two-class manifest: one orchestrator, two workers.""" + return ExperimentManifest.load( + { + "condition": "O-test", + "roster": [ + { + "id": "worker-glm", + "kind": "worker", + "role": "implementer", + "count": 2, + "endpoint": "databricks/glm", + "model_revision": "glm-5.2", + "prompt": PROMPT_REF, + "generation": { + "max_output_tokens": 4096, + "context_window_tokens": 128000, + }, + }, + { + "id": "orch-opus", + "kind": "orchestrator", + "role": "lead", + "count": 1, + "endpoint": "databricks/opus", + "model_revision": "opus-4.8", + "prompt": PROMPT_REF, + "generation": { + "max_output_tokens": 8192, + "context_window_tokens": 200000, + }, + }, + ], + "prices": { + "databricks/glm": { + "input_per_million_usd": 0.5, + "cached_input_per_million_usd": 0.1, + "output_per_million_usd": 1.5, + }, + "databricks/opus": { + "input_per_million_usd": 15.0, + "cached_input_per_million_usd": 1.5, + "output_per_million_usd": 75.0, + }, + }, + "trial_budget": {"timeout_seconds": 3600}, + } + ) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py new file mode 100644 index 0000000000..2d88e339f5 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py @@ -0,0 +1,156 @@ +"""just benchmark must default to leaderboard-eligible settings.""" + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +_SCRIPT = Path(__file__).parents[2] / "scripts" / "benchmark.py" +_spec = importlib.util.spec_from_file_location("benchmark", _SCRIPT) +benchmark = importlib.util.module_from_spec(_spec) +sys.modules["benchmark"] = benchmark +_spec.loader.exec_module(benchmark) + + +@pytest.fixture +def state_dir(tmp_path, monkeypatch): + monkeypatch.setattr(benchmark, "STATE_DIR", tmp_path / ".benchmark") + return tmp_path / ".benchmark" + + +def test_defaults_are_leaderboard_eligible(): + args = benchmark.parse_args([]) + assert args.attempts == 5 + assert args.dataset is None and args.path is None # dataset default applied later + argv = benchmark.leaderboard_argv(args, Path("prov.json"), Path("linux-bin")) + assert argv[argv.index("--dataset") + 1] == "terminal-bench/terminal-bench-2-1" + assert argv[argv.index("--attempts") + 1] == "5" + assert argv[argv.index("--manifest") + 1].endswith("tb-cobol-sonnet-haiku.yaml") + assert argv[argv.index("--agent-bin-dir") + 1] == "linux-bin" + # In-container agents reach the host relay through the forwarder gateway. + assert argv[argv.index("--relay-gateway") + 1] == ( + f"host.docker.internal:{benchmark.RELAY_HTTP_PORT}" + ) + + +def test_selectors_pass_through(): + args = benchmark.parse_args( + ["--path", "/tmp/task", "-i", "cobol*", "-x", "flaky*", "-k", "1", + "--job-name", "smoke", "--dry-run"] + ) + argv = benchmark.leaderboard_argv(args, Path("p.json"), Path("b")) + assert argv[argv.index("--path") + 1] == "/tmp/task" + assert argv[argv.index("--include-task") + 1] == "cobol*" + assert argv[argv.index("--exclude-task") + 1] == "flaky*" + assert argv[argv.index("--attempts") + 1] == "1" + assert "--dry-run" in argv + assert "--dataset" not in argv + + +def test_state_is_generated_once_and_reused(state_dir): + first = benchmark.load_state() + second = benchmark.load_state() + assert first["user_secret_key"] == second["user_secret_key"] + assert first["owner_secret_key"] != first["user_secret_key"] + assert len(first["user_pubkey"]) == 64 + stored = json.loads((state_dir / "state.json").read_text()) + assert "user_pubkey" not in stored # derived, never persisted + + +def test_provisioner_config_pins_user_and_keeps_channels(state_dir, tmp_path, monkeypatch): + monkeypatch.setenv("FAKE_KEY_ENV", "sk-test") + endpoints = tmp_path / "endpoints.json" + endpoints.write_text( + json.dumps({"model-a": {"provider": "anthropic", "api_key_env": "FAKE_KEY_ENV"}}) + ) + state = benchmark.load_state() + path = benchmark.write_provisioner_config(state, endpoints) + config = json.loads(path.read_text()) + assert config["user_secret_key"] == state["user_secret_key"] + assert config["archive_on_teardown"] is False + assert config["llm_api_keys"] == {"model-a": "sk-test"} + assert str(benchmark.RELAY_HTTP_PORT) in config["relay_http_url"] + # Both views dial the relay's canonical host-bound address; inside the + # task container the loopback forwarder bridges it to the host gateway. + assert config["relay_ws_url"] == f"ws://localhost:{benchmark.RELAY_HTTP_PORT}" + assert config["relay_http_url"].startswith("http://localhost:") + + +def test_provisioner_config_missing_api_key_is_explicit(state_dir, tmp_path, monkeypatch): + monkeypatch.delenv("MISSING_KEY_ENV", raising=False) + endpoints = tmp_path / "endpoints.json" + endpoints.write_text( + json.dumps({"model-a": {"provider": "x", "api_key_env": "MISSING_KEY_ENV"}}) + ) + with pytest.raises(SystemExit, match="MISSING_KEY_ENV"): + benchmark.write_provisioner_config(benchmark.load_state(), endpoints) + + +def test_env_file_wires_owner_and_ports(state_dir): + state = benchmark.load_state() + env_path = benchmark.write_env_file(state) + env = dict( + line.split("=", 1) for line in env_path.read_text().splitlines() if line + ) + assert env["RELAY_OWNER_PUBKEY"] == state["owner_pubkey"] + assert env["BUZZ_HTTP_PORT"] == str(benchmark.RELAY_HTTP_PORT) + assert env["BUZZ_PG_HOST_PORT"] == str(benchmark.PG_HOST_PORT) + assert env["BUZZ_REQUIRE_RELAY_MEMBERSHIP"] == "true" + + +def test_compose_command_isolates_the_project(state_dir): + command = benchmark.compose_command("up", "-d") + assert command[:2] == ["docker", "compose"] + assert command[command.index("--project-name") + 1] == "buzz-benchmark" + files = [command[i + 1] for i, part in enumerate(command) if part == "-f"] + assert any(f.endswith("deploy/compose/compose.yml") for f in files) + assert any(f.endswith("compose.benchmark.yml") for f in files) + + +def test_bring_up_self_heals_a_stale_credential_volume(monkeypatch): + calls = [] + + def fake_run(command, check=True): + calls.append(command) + if len(calls) == 1: # first up fails against the stale volume + raise benchmark.subprocess.CalledProcessError(1, command) + + monkeypatch.setattr(benchmark.subprocess, "run", fake_run) + monkeypatch.setattr(benchmark, "stale_credential_volume", lambda state: True) + benchmark.bring_up_stack({}) + assert [c[-3:] for c in calls] == [ + ["up", "-d", "--wait"], + [str(benchmark.COMPOSE_FILES[-1]), "down", "-v"], + ["up", "-d", "--wait"], + ] + + +def test_bring_up_reraises_unrelated_failures(monkeypatch): + def fake_run(command, check=True): + raise benchmark.subprocess.CalledProcessError(1, command) + + monkeypatch.setattr(benchmark.subprocess, "run", fake_run) + monkeypatch.setattr(benchmark, "stale_credential_volume", lambda state: False) + with pytest.raises(benchmark.subprocess.CalledProcessError): + benchmark.bring_up_stack({}) + + +def test_fresh_resets_volumes_and_gui_state(tmp_path, monkeypatch): + commands = [] + monkeypatch.setattr( + benchmark.subprocess, "run", lambda cmd, check=True: commands.append(cmd) + ) + monkeypatch.setattr(benchmark.sys, "platform", "darwin") + monkeypatch.setattr(benchmark.Path, "home", classmethod(lambda cls: tmp_path)) + gui_state = tmp_path / "Library" / "WebKit" / benchmark.GUI_BUNDLE_IDENTIFIER + gui_state.mkdir(parents=True) + (gui_state / "localstorage.sqlite3").touch() + + benchmark.reset_environment() + + assert ["down", "-v"] == commands[0][-2:] + assert not gui_state.exists() + assert benchmark.parse_args(["--fresh"]).fresh + assert not benchmark.parse_args([]).fresh diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py new file mode 100644 index 0000000000..0f82578369 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py @@ -0,0 +1,72 @@ +"""Keygen and NIP-OA attestation unit tests.""" + +from __future__ import annotations + +import hashlib +import json + +import coincurve +from harbor_buzz_testbed.keys import ( + compute_auth_tag, + encode_nsec, + generate_keypair, +) + +# Produced by the Rust reference implementation +# (crates/buzz-sdk/examples/compute_auth_tag.rs) for owner secret 0x...03 and +# agent pubkey "a" * 64. Pins the preimage format across implementations. +RUST_OWNER_SECRET = "0" * 63 + "3" +RUST_AGENT_PUBKEY = "a" * 64 +RUST_TAG = [ + "auth", + "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + "", + "20105c618d6e5d8f559cffb6f0d7a7b4f44f3a567e1be94c96378d45ac3625da" + "34c2e7357ea1d3ce980978334546b3e740c155e81b833ebe140d519d39ed8867", +] + + +def preimage_digest(agent_pubkey: str, conditions: str) -> bytes: + return hashlib.sha256( + f"nostr:agent-auth:{agent_pubkey}:{conditions}".encode() + ).digest() + + +def test_generate_keypair_is_fresh_and_hex(): + first, second = generate_keypair(), generate_keypair() + assert first.secret_key != second.secret_key + assert first.pubkey != second.pubkey + assert len(first.secret_key) == 64 + assert len(first.pubkey) == 64 + int(first.secret_key, 16) + int(first.pubkey, 16) + + +def test_auth_tag_shape_and_owner_pubkey(): + tag = json.loads(compute_auth_tag(RUST_OWNER_SECRET, RUST_AGENT_PUBKEY)) + assert tag[0] == "auth" + assert tag[1] == RUST_TAG[1] # same owner pubkey as the Rust implementation + assert tag[2] == "" + + +def test_auth_tag_signature_verifies_over_nip_oa_preimage(): + agent = generate_keypair() + tag = json.loads(compute_auth_tag(RUST_OWNER_SECRET, agent.pubkey)) + owner_pubkey = coincurve.PublicKeyXOnly(bytes.fromhex(tag[1])) + assert owner_pubkey.verify(bytes.fromhex(tag[3]), preimage_digest(agent.pubkey, "")) + + +def test_rust_reference_tag_verifies_under_python_preimage(): + """The Rust-signed vector must verify against our preimage construction.""" + owner_pubkey = coincurve.PublicKeyXOnly(bytes.fromhex(RUST_TAG[1])) + assert owner_pubkey.verify( + bytes.fromhex(RUST_TAG[3]), preimage_digest(RUST_AGENT_PUBKEY, "") + ) + + +def test_encode_nsec_matches_nip19_vector(): + # NIP-19 reference vector from the spec. + assert ( + encode_nsec("67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa") + == "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5" + ) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py new file mode 100644 index 0000000000..5a6b40d8cf --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py @@ -0,0 +1,123 @@ +"""Live provisioning tests against a running benchmark stack. + +Gated by BUZZ_TESTBED_LIVE=1 with stack coordinates in the environment: + BUZZ_TESTBED_RELAY_HTTP (default http://localhost:3000) + BUZZ_TESTBED_RELAY_WS (default ws://host.docker.internal:3000) + BUZZ_TESTBED_OWNER_KEY relay owner secret key (hex) + BUZZ_TESTBED_PG_DSN benchmark Postgres DSN +""" + +from __future__ import annotations + +import os +import uuid + +import psycopg +import pytest +from harbor_buzz_testbed.buzz_cli import BuzzCli, BuzzCliError +from harbor_buzz_testbed.provisioner import ( + BuzzTrialProvisioner, + ProvisioningError, + TestbedConfig, +) + +pytestmark = pytest.mark.skipif( + os.environ.get("BUZZ_TESTBED_LIVE") != "1", + reason="live testbed suite; set BUZZ_TESTBED_LIVE=1 with a running stack", +) + + +@pytest.fixture() +def provisioner() -> BuzzTrialProvisioner: + owner_key = os.environ.get("BUZZ_TESTBED_OWNER_KEY") + dsn = os.environ.get("BUZZ_TESTBED_PG_DSN") + if not owner_key or not dsn: + pytest.fail("BUZZ_TESTBED_OWNER_KEY and BUZZ_TESTBED_PG_DSN are required") + return BuzzTrialProvisioner( + TestbedConfig( + relay_http_url=os.environ.get( + "BUZZ_TESTBED_RELAY_HTTP", "http://localhost:3000" + ), + relay_ws_url=os.environ.get( + "BUZZ_TESTBED_RELAY_WS", "ws://host.docker.internal:3000" + ), + owner_secret_key=owner_key, + postgres_dsn=dsn, + llm_api_keys={ + "databricks/glm": "test-glm-key", + "databricks/opus": "test-opus-key", + }, + ) + ) + + +def cli_for(provisioner: BuzzTrialProvisioner, credential) -> BuzzCli: + return provisioner._cli_for(credential) + + +def test_create_is_idempotent_and_isolated(provisioner, manifest): + run_id = f"live-{uuid.uuid4().hex[:8]}" + trial_a = str(uuid.uuid4()) + trial_b = str(uuid.uuid4()) + + provisioner.healthcheck() + handle_a = provisioner.create_trial(run_id, trial_a, manifest) + handle_b = provisioner.create_trial(run_id, trial_b, manifest) + try: + # Shape: one credential per roster slot, orchestrator first. + assert len(handle_a.credentials) == 3 + assert handle_a.credentials[0].role == "orchestrator" + assert handle_a.manifest_hash == manifest.sha256 + + # Idempotency: same key returns the stored handle, keys included. + again = provisioner.create_trial(run_id, trial_a, manifest) + assert again == handle_a + + # Isolation: trials get distinct channels and fresh keys... + assert handle_a.channel_id != handle_b.channel_id + keys_a = {c.nostr_secret_key for c in handle_a.credentials} + keys_b = {c.nostr_secret_key for c in handle_b.credentials} + assert not keys_a & keys_b + + # ...and trial B's orchestrator cannot see trial A's channel. + cli_a = cli_for(provisioner, handle_a.credentials[0]) + cli_b = cli_for(provisioner, handle_b.credentials[0]) + cli_a.run( + "messages", + "send", + "--channel", + handle_a.channel_id, + "--content", + "trial A secret", + ) + foreign_read = cli_b.run( + "messages", "get", "--channel", handle_a.channel_id, "--limit", "10" + ) + assert foreign_read == [], "cross-trial read must return nothing" + with pytest.raises(BuzzCliError, match="private"): + cli_b.run("channels", "join", "--channel", handle_a.channel_id) + + # Members can read their own channel. + own_read = cli_a.run( + "messages", "get", "--channel", handle_a.channel_id, "--limit", "10" + ) + assert [m["content"] for m in own_read] == ["trial A secret"] + + # Same trial key + different manifest must be rejected, not silently + # reprovisioned. + altered = manifest.model_copy(update={"condition": "O-altered"}) + with pytest.raises(ProvisioningError, match="already provisioned"): + provisioner.create_trial(run_id, trial_a, altered) + finally: + provisioner.teardown(handle_a) + provisioner.teardown(handle_b) + + # Teardown is idempotent and stamps archived_at. + provisioner.teardown(handle_a) + with psycopg.connect(os.environ["BUZZ_TESTBED_PG_DSN"]) as conn: + row = conn.execute( + "SELECT archived_at FROM benchmark.trial_manifest" + " WHERE run_id = %s AND trial_id = %s", + (run_id, trial_a), + ).fetchone() + assert row is not None and row[0] is not None diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py new file mode 100644 index 0000000000..9620be4bc8 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py @@ -0,0 +1,118 @@ +"""Provisioner unit tests — no relay or Postgres required.""" + +from __future__ import annotations + +import hashlib +import json + +import coincurve +import pytest +from harbor_buzz_testbed.provisioner import ( + BuzzTrialProvisioner, + ProvisioningError, + TestbedConfig, +) + +OWNER_SECRET = "0" * 63 + "3" + + +def config(**overrides) -> TestbedConfig: + defaults = dict( + relay_http_url="http://localhost:3000", + relay_ws_url="ws://host.docker.internal:3000", + owner_secret_key=OWNER_SECRET, + postgres_dsn="postgresql://unused", + llm_api_keys={"databricks/glm": "glm-key", "databricks/opus": "opus-key"}, + ) + defaults.update(overrides) + return TestbedConfig(**defaults) + + +def test_mint_credentials_expands_roster(manifest): + credentials = BuzzTrialProvisioner(config())._mint_credentials(manifest) + assert [c.agent_id for c in credentials] == [ + "orch-opus-1", + "worker-glm-1", + "worker-glm-2", + ] + assert credentials[0].role == "orchestrator" + assert credentials[0].llm_api_key == "opus-key" + assert {c.llm_api_key for c in credentials[1:]} == {"glm-key"} + + +def test_mint_credentials_keys_are_fresh_and_attested(manifest): + provisioner = BuzzTrialProvisioner(config()) + first = provisioner._mint_credentials(manifest) + second = provisioner._mint_credentials(manifest) + all_secrets = [c.nostr_secret_key for c in first + second] + assert len(all_secrets) == len(set(all_secrets)), "keys must never be reused" + owner_pubkey = coincurve.PrivateKey(bytes.fromhex(OWNER_SECRET)).public_key_xonly + for credential in first: + tag = json.loads(credential.nostr_auth_tag) + assert tag[:3] == ["auth", owner_pubkey.format().hex(), ""] + digest = hashlib.sha256( + f"nostr:agent-auth:{credential.nostr_pubkey}:".encode() + ).digest() + assert owner_pubkey.verify(bytes.fromhex(tag[3]), digest) + + +def test_mint_user_is_attested_and_not_an_agent(): + provisioner = BuzzTrialProvisioner(config()) + user = provisioner._mint_user() + assert user.agent_id == "user" + assert user.role == "user" + assert user.llm_endpoint == "" and user.llm_api_key == "" + owner_pubkey = coincurve.PrivateKey(bytes.fromhex(OWNER_SECRET)).public_key_xonly + tag = json.loads(user.nostr_auth_tag) + assert tag[:3] == ["auth", owner_pubkey.format().hex(), ""] + + +def test_pinned_user_secret_reuses_one_identity(): + pinned = "7" * 64 + provisioner = BuzzTrialProvisioner(config(user_secret_key=pinned)) + first = provisioner._mint_user() + second = provisioner._mint_user() + assert first.nostr_secret_key == pinned + assert first.nostr_pubkey == second.nostr_pubkey + expected = coincurve.PrivateKey(bytes.fromhex(pinned)).public_key_xonly + assert first.nostr_pubkey == expected.format().hex() + # Still a user, still attested by the owner. + assert first.role == "user" + owner_pubkey = coincurve.PrivateKey(bytes.fromhex(OWNER_SECRET)).public_key_xonly + assert json.loads(first.nostr_auth_tag)[1] == owner_pubkey.format().hex() + + +def test_teardown_skips_archiving_when_disabled(): + provisioner = BuzzTrialProvisioner(config(archive_on_teardown=False)) + # Any attribute access would fail on this handle — teardown must return + # before touching the CLI or Postgres. + provisioner.teardown(handle=None) + + +def test_mint_credentials_missing_api_key_is_explicit(manifest): + provisioner = BuzzTrialProvisioner(config(llm_api_keys={})) + with pytest.raises(ProvisioningError, match="databricks/"): + provisioner._mint_credentials(manifest) + + +def test_lock_key_is_deterministic_and_distinct(): + calls: list[int] = [] + + class FakeConn: + def execute(self, _query, params): + calls.append(params[0]) + + BuzzTrialProvisioner._lock_trial(FakeConn(), "run-a", "trial-1") + BuzzTrialProvisioner._lock_trial(FakeConn(), "run-a", "trial-1") + BuzzTrialProvisioner._lock_trial(FakeConn(), "run-a", "trial-2") + assert calls[0] == calls[1] + assert calls[0] != calls[2] + assert all(-(2**63) <= key < 2**63 for key in calls) + + +def test_healthcheck_fails_fast_when_relay_down(): + provisioner = BuzzTrialProvisioner( + config(relay_http_url="http://localhost:1", postgres_dsn="postgresql://unused") + ) + with pytest.raises(ProvisioningError, match="relay unreachable"): + provisioner.healthcheck() diff --git a/benchmarks/harbor-buzz-orchestra/testbed/uv.lock b/benchmarks/harbor-buzz-orchestra/testbed/uv.lock new file mode 100644 index 0000000000..814f4d3527 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/uv.lock @@ -0,0 +1,2588 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76" }, +] + +[[package]] +name = "coincurve" +version = "21.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/a2/f2a38eb05b747ed3e54e1be33be339d4a14c1f5cc6a6e2b342b5e8160d51/coincurve-21.0.0.tar.gz", hash = "sha256:8b37ce4265a82bebf0e796e21a769e56fdbf8420411ccbe3fafee4ed75b6a6e5" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/61/a2d9e109f99b6f5e65e653ac998b0944c5b82c568ac142fcbb381a4803be/coincurve-21.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f60ad56113f08e8c540bb89f4f35f44d434311433195ffff22893ccfa335070c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/5a/2da75ee00a722ef1fa068ada3bc34c564595ead86fef573434e2f0cb0a5c/coincurve-21.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1cb1cd19fb0be22e68ecb60ad950b41f18b9b02eebeffaac9391dc31f74f08f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/50/6bf0bf7e8a9a9dd419ecc1e479dcb9fbfe657029276ad703806a25a2bef2/coincurve-21.0.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05d7e255a697b3475d7ae7640d3bdef3d5bc98ce9ce08dd387f780696606c33b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bd/ab/9e89908fdd09ad522938085587aaa821b022f4def16c286c5580cfc85811/coincurve-21.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/75/050b6fd08978de85a7b480f0f220ab6a30967c0910119f3096a8dd40befc/coincurve-21.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b04778b75339c6e46deb9ae3bcfc2250fbe48d1324153e4310fc4996e135715" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/62/2740ba0cafebf45708633635fecadcbe582d7a3ed1ce8b4637921feceaf8/coincurve-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8efcbdcd50cc219989a2662e6c6552f455efc000a15dd6ab3ebf4f9b187f41a3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/14/1f27c3048c4084fa85ef65f42a4ca631f2b184336e6d9446fecec20e0a7f/coincurve-21.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6df44b4e3b7acdc1453ade52a52e3f8a5b53ecdd5a06bd200f1ec4b4e250f7d9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/22/7ec3ec4c8e7764daa25767d6674cb5741ea2d9b39ff758e9918d22a4b49b/coincurve-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bcc0831f07cb75b91c35c13b1362e7b9dc76c376b27d01ff577bec52005e22a8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/60/87982b7499943ab12605df7b14f6001fff331aca0881b260682461e2309d/coincurve-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:5dd7b66b83b143f3ad3861a68fc0279167a0bae44fe3931547400b7a200e90b1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/c0/65b60b371579570931daca8a3f67debfc1482908b8ed03432297274a27da/coincurve-21.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:78dbe439e8cb22389956a4f2f2312813b4bd0531a0b691d4f8e868c7b366555d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/40/cce55adaec37a588eb24b67da8eb68926546458e12ed2c4c2a21deb93d4c/coincurve-21.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9df5ceb5de603b9caf270629996710cf5ed1d43346887bc3895a11258644b65b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/7a/628a30281d246ce98aea56592e0c8e79b03a93ee8b85d688db3388130c2d/coincurve-21.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:154467858d23c48f9e5ab380433bc2625027b50617400e2984cc16f5799ab601" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/cc/719c5da31e6ba07e438abcf962f7a365eb69a06a0621ca4f2a484f344e09/coincurve-21.0.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57f07c44d14d939bed289cdeaba4acb986bba9f729a796b6a341eab1661eedc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/ee/dd14237013d732e7fc3248c0c33a1d36b88b5378dfa3e624a50a23fb6f19/coincurve-21.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fb03e3a388a93d31ed56a442bdec7983ea404490e21e12af76fb1dbf097082a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/05/eaa7f36a03376ced1c19e0cb563341cc83fe48f5734b2effe8f16d0ee0ab/coincurve-21.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d09ba4fd9d26b00b06645fcd768c5ad44832a1fa847ebe8fb44970d3204c3cb7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/39/32/fc75f1dd914ac95eb2704425c7ca1a9f509f982e15d05e0ca895b9e6ea9c/coincurve-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1a1e7ee73bc1b3bcf14c7b0d1f44e6485785d3b53ef7b16173c36d3cefa57f93" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/4b/8c6e65b5755e26fc02077803879747615c1c327047328d1784bccb4ff4c3/coincurve-21.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ad05952b6edc593a874df61f1bc79db99d716ec48ba4302d699e14a419fe6f51" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/bc/d0a743305ff9fa26e72b4c77b534d5958ec8030b3772555a7172a0c134e5/coincurve-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d2bf350ced38b73db9efa1ff8fd16a67a1cb35abb2dda50d89661b531f03fd3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/44/ab082e2dc8c9a45774f1bb9961f58b43c0882b866f5c469ead932d45a35d/coincurve-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:54d9500c56d5499375e579c3917472ffcf804c3584dd79052a79974280985c74" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/94/407f6fc811310f15b1fc7255f436f6a9040854213beeb10093f56b5b7fd3/coincurve-21.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:773917f075ec4b94a7a742637d303a3a082616a115c36568eb6c873a8d950d18" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001" }, +] + +[[package]] +name = "datasets" +version = "5.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "dill" }, + { name = "filelock" }, + { name = "fsspec", extra = ["http"] }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "multiprocess" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pyarrow" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "xxhash" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d9/85/ce4f780c32f7e36d71257f1c27e8ba898ebe379cb54f211f5f2013f2c219/datasets-5.0.0.tar.gz", hash = "sha256:83dbbbdb07a33b82192b8c419deb18739b138ee2ce1a322d55ce6b100954ec1a" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/66/73034ad30b59f13439b75e620989dacba4c047256e358ba7c2e9ec98ea22/datasets-5.0.0-py3-none-any.whl", hash = "sha256:7dd34927a0fd7046e98aad5cb9430e699c373238a15befa7b9bf22b991a7fee6" }, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d" }, +] + +[[package]] +name = "dirhash" +version = "0.5.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "scantree" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1d/70/49f93897f3a4f7ab5f20a854ebc91aad47854e9fb2cd169e3a4452fa3f5e/dirhash-0.5.0.tar.gz", hash = "sha256:e60760f0ab2e935d8cb088923ea2c6492398dca42cec785df778985fd4cd5386" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/1f/c8bf92552b7f0a13b9f12b85e3de8df6d9814240e0f8ce8f37433df028b3/dirhash-0.5.0-py3-none-any.whl", hash = "sha256:523dfd6b058c64f45b31604376926c6e2bd2ea301d0df23095d4055674e38b09" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, +] + +[[package]] +name = "fastapi" +version = "0.138.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/a9/9f8f7e00195c29836e9bf58bbbaf579e29878b8a67851efff93d9b6d4eb7/fastapi-0.138.2.tar.gz", hash = "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615" }, +] + +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" }, +] + +[[package]] +name = "fsspec" +version = "2026.4.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2" }, +] + +[package.optional-dependencies] +http = [ + { name = "aiohttp" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd" }, +] + +[[package]] +name = "harbor" +version = "0.16.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "datasets" }, + { name = "dirhash" }, + { name = "fastapi" }, + { name = "filelock" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "litellm" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "shortuuid" }, + { name = "supabase" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "typer" }, + { name = "uvicorn" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/37/46/49008d2a07f98ce0eb333366546cac8451d6e80c8068ac7687711c56c862/harbor-0.16.1.tar.gz", hash = "sha256:115c39ad76b3d1dbfd82ff51af904adf22acac240c90b12eb34e8cb7d46f8cc4" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/f3/5abb440eff288c5fef89079e49b873e825a4b563b874b849dc728d551fab/harbor-0.16.1-py3-none-any.whl", hash = "sha256:26264bb011baf25f44d5264ee74e1a0882eb936f9883f8618bce1573878c3f66" }, +] + +[[package]] +name = "harbor-buzz-orchestra" +version = "0.1.0" +source = { editable = "../" } +dependencies = [ + { name = "harbor" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "harbor", specifier = ">=0.16.1,<0.18" }, + { name = "pydantic", specifier = ">=2.11" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" }, +] +provides-extras = ["dev"] + +[[package]] +name = "harbor-buzz-testbed" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "coincurve" }, + { name = "harbor-buzz-orchestra" }, + { name = "psycopg", extra = ["binary"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "coincurve", specifier = ">=20" }, + { name = "harbor-buzz-orchestra", editable = "../" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" }, +] +provides-extras = ["dev"] + +[[package]] +name = "hf-xet" +version = "1.5.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/24/5e0c28f80371c17d49fed004597d9d132cb75c1f6f53db2cb95f459d2312/hf_xet-1.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3474760d10e3bb6f92ff3f024fcb00c0b3e4001e9b035c7483e49a5dd17aa70f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/17/261ba565b6a4d960fb478f61fdf919c0be5824645aaf1c319eca660c1611/hf_xet-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6762d89b9e3267dfd502b29b2a327b4525f33b17e7b509a78d94e2151a30ce30" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/44/7ffdc2e184b0d41fc0f683ba3936ef669ab63cf242cf36ef50e57d683668/hf_xet-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf67e6ed10260cef62e852789dc91ebb03f382d5bdc4b1dbeb64763ea275e7d6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/63/b6/788060d5aa4d5e671f1a31bf69624c314eb2d8babab3aa562f9e5d53444e/hf_xet-1.5.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c6b6cd08ca095058780b50b8ce4d6cbf6787bcf27841705d58a9d32246e3e47a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/93/c5540cbd6b55529b7dc42f6734e88cebee21aefbea34128b66229df56c57/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1af0de8ca6f190d4294a28b88023db64a1e2d1d719cab044baf75bec569e7a9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/f3/9d8ceab30f44f36c1679b1b8683054c71a0dadc787dbf07421891742d3ca/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4f561cbbb92f80960772059864b7fb07eae879adde1b2e781ec6f86f6ac26c59" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/54/27ed9a5e2cc583b4df82f75a03a4df8dbf55f5a9fa1f47f1fadfb20dbeac/hf_xet-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e7dbb40617410f432182d918e37c12303fe6700fd6aa6c5964e30a535a4461d6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/12/ecb2fc8d45e767580e3a37faa97cb895608b614965567efb4f18cff67e27/hf_xet-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6071d5ccb4d8d2cbd5fea5cc798da4f0ba3f44e25369591c4e89a4987050e61d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.21.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/77/ce3331f40cb2d021fe9b24c46c41e72faf74493621138e5eddac12bf5e1c/huggingface_hub-1.21.0.tar.gz", hash = "sha256:a44f222cd8f2f7c2eade30b5e7a04cac984a3235fa61ea87a0a5a31db77d561f" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl", hash = "sha256:eadaa3678c512c82aea69e8675d90a184861e68de32f1105668628b4dce0e7cd" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.9.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7d/f9/97f2ca8bb3ec6e4b1d64f983ebe98b9a192faddff67fac3d6303a537e670/importlib_metadata-8.9.0-py3-none-any.whl", hash = "sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, +] + +[[package]] +name = "litellm" +version = "1.90.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/00/4187e33818d0de19fd602214ce15a6e1cfa5bf04fb50a6e59606214a92df/litellm-1.90.2.tar.gz", hash = "sha256:b536603894ba2a0ccd14a6f1ebeb5f46cc35b19d4537e8fda7bfdfc7757f19d3" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/30/3afa7b212ce1f9bb8b6fa5f58c631f437c1d7b3a025e4e4ed6b01b3d28ad/litellm-1.90.2-py3-none-any.whl", hash = "sha256:6fb46f485150cc861be62a62d670f94d33adbd26991091befeb51bcdfdad34f6" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" }, +] + +[[package]] +name = "multiprocess" +version = "0.70.19" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "dill" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/f2/e783ac7f2aeeed14e9e12801f22529cc7e6b7ab80928d6dcce4e9f00922d/multiprocess-0.70.19.tar.gz", hash = "sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/45/8004d1e6b9185c1a444d6b55ac5682acf9d98035e54386d967366035a03a/multiprocess-0.70.19-py310-none-any.whl", hash = "sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/c2/dec9722dc3474c164a0b6bcd9a7ed7da542c98af8cabce05374abab35edd/multiprocess-0.70.19-py311-none-any.whl", hash = "sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl", hash = "sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/74/d2c27e03cb84251dfe7249b8e82923643c6d48fa4883b9476b025e7dc7eb/multiprocess-0.70.19-py313-none-any.whl", hash = "sha256:8d5eb4ec5017ba2fab4e34a747c6d2c2b6fecfe9e7236e77988db91580ada952" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/61/af9115673a5870fd885247e2f1b68c4f1197737da315b520a91c757a861a/multiprocess-0.70.19-py314-none-any.whl", hash = "sha256:e8cc7fbdff15c0613f0a1f1f8744bef961b0a164c0ca29bdff53e9d2d93c5e5f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5" }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865" }, +] + +[[package]] +name = "openai" +version = "2.44.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, +] + +[[package]] +name = "postgrest" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/22/88c470d8838d2678a44e0172d061630b8837cba3fb7fb492e28f6578c309/postgrest-2.31.0.tar.gz", hash = "sha256:2f395d84b2ee34fc57622ff2f711df603e2ede625f98e5015240741888f7bd0c" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ea/3e/41909586cb148db0259e0208310067afda4cc097f4c7a779e829c1e68c46/postgrest-2.31.0-py3-none-any.whl", hash = "sha256:c2fd47c94e13ee8335111c4f03c9a24ea9766ce9d35fc3cd7330057c9e7ea0c3" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, +] + +[[package]] +name = "realtime" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/34/54a1eaaefa24db5cb12596fd74792e08efa53ed30dc5bce2c0a68ded6146/realtime-2.31.0.tar.gz", hash = "sha256:9e641cb4d77ca0fe768515f8cf9f83550c79f49ce1550a95afc2dc0e252be8c9" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/60/164246615e8b059f6d53d34648a0784260421ec98a07eb1e45160f063221/realtime-2.31.0-py3-none-any.whl", hash = "sha256:f6e494b53d6a6e80b6efcee6711c8dd40413a52e766271de1bce8ced6c36cc1d" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, +] + +[[package]] +name = "regex" +version = "2026.6.28" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/05/e4f219230e11e774a6c9987d2ab0d0c6b8573e13a17e143d0015bee710ef/regex-2026.6.28.tar.gz", hash = "sha256:3cb4b6c5cb3060cc31efdc1fbb27c25fb9b29044afd87e40601a1c4d9db54342" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/21/44aa415873032056c43eac21c67285deb2cf66cddb2a964c3cdc8f803efc/regex-2026.6.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:81cc5793ad33a10444445e8d29d3c73e752c8fb2e120772d70fcb6d41df40fe1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/5f/30d4116093c2128099f78b6990dfc1698fdbf3ee528f1e1c647378034c79/regex-2026.6.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e18225243250a1f7d7e5e5d883f3b96465cd79031acf5c6db902b7025f2125d9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/0e/ca20a0e0de49837e6337603a91ab77556aa27033ac5b975615d98698cfb3/regex-2026.6.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ecd1638b1c2db1f2d01c182a4b0d3e2e88b0e99910320a745c1727ee3638ddab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/50/11/c013422a7e2c59946df8ac93e792a4922c98287f2a2181341603c78a5d98/regex-2026.6.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4303ebe16b74eeb3fe2715745023266fea92fd44a23f3e7bb2fb48c7a7bbc195" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/95/1309645a0e1ee6fb91d954501da57a0b33d50ad2a9acb313702851a7054e/regex-2026.6.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56b856b70b96c381d837f609eee442a1bd320cd2159f5c294b679552fb1a7eaf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/06/491802db47c6f5e2904ffa2518ad3ac27fe6bbf5a66d73210a95cc080d47/regex-2026.6.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f74675ab76ab1d005ffba4dee308e53e89efc22be6e9f9fae5b539a3f81bdff2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/60/3ba57840bcc7e2367090360de0c15a5ba6ad22be89314251105f2e943f43/regex-2026.6.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90581684565a93f7258af1e5d3f41ef20d7d7c61f2a428183a342bcb65485e38" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/27/af1eb74e9a78c782b3e450b611a595e44906da8a5107e1227f4a7fd0480b/regex-2026.6.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:28f9e6c28f9b90f6f784595a33240a57e181e61b6ee3dc259b25c61e356d1aa3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/18/fdd4c883a39e3ed00d669062af1135809bfd3281bf528150849fbd68825b/regex-2026.6.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:378a71d861fc7c8806b04ac5b133d53c0e774f92f5d9663a539872d3fa2b0417" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1c/79/0aabe34b8482dcadf64355f70f96e22eba5ec6c1efb33563f89654f4061c/regex-2026.6.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc199874ecd6267a49b111052250825bfe19b5101b23b2ba80f54efa3e0994e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/2c/c973323306a27c9db7d160e9584eb7e0ece2a96224ccb0d39060558b31f9/regex-2026.6.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b916a10431494ef4b4d62c6c89cab6426af7873125b8cd6c15811bf5fc58eec8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/df/9ca3e378e352242a4cb45573a5e9162c3ee791507702a23966fa559e36b5/regex-2026.6.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2e27727fba075f1e4409416d2f537d4c30fc11f012ea507f7bd74d3e19ecb57a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/3e/3e31e255c4971f53cbce6306b5e3c76cbd3735a54f419bb3b2f194e9f68c/regex-2026.6.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:700fc6a7844bb2c4149292ac79d1df8841a00acd4d45cd32c1ebc7bcc1fd0da8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/72/01/d36561c21c3033d7eeb31d51b491916817de7861acefccc5fc9db8a5037c/regex-2026.6.28-cp312-cp312-win32.whl", hash = "sha256:03376d60b6a11aecb88a79fa2be06b40faa01c6693bc31ef69435cd4818b9463" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/59/bbbb0591f38b18c65977cd65ce64749eba1c1996c99ac04e900fc30c0dcb/regex-2026.6.28-cp312-cp312-win_amd64.whl", hash = "sha256:fbd2ded482bf99e6651992bbfcde460272724d4bbc49ef3d6b46d9312867ec84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/06/be4f6b337d773ae5739a1bc238f97c16926e72017243735853c030f4c628/regex-2026.6.28-cp312-cp312-win_arm64.whl", hash = "sha256:37294d3d7ddb64c7e89184b2894e0f8f0a19c514bc59513d71fe692c3a8d5fc6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/53/d5c1b3cc0b5a0c985563ad6fac93d73ff2b300cb84342d89f044625d6bc7/regex-2026.6.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b295a83426e0e44e9e60fde99789e181bd26788a1890ae7fe2a24c69bb6246ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8d/9f/0c3503e819e91ca0e7a901a8e989ebf840ac7c7aea20b1fc7f31b6759f77/regex-2026.6.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c31665c0deb5c111557a1cac8c27bd5629e2f9e7fd5058900a03576c33b601c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/7f/cd004e13fcad23b3794a82307dfd222e6365eb7f598bd3caab148a830bff/regex-2026.6.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6bf295f2c59de77d1ea7de053607ae4dc9ceb3d57bbb6c7ec51ef4acc4ccff94" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/73/4c/293fb34586fbcdc47eac436069e9c11f71fae5dadfd4889b475d7d2e5f7a/regex-2026.6.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17c077586770f67e05bbffeba07fbee6b2b22244f4d4caf8d94e59d574befe04" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/fa/c0cd1a90b7d12d9dc155cfc8bdea8df9720988ea5b07e8fa1eccbd0ab2dd/regex-2026.6.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e6cb5a61486f9062397d2e189573b39d38ecfaed698fd9fb6e2756a8ebb8762" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/db/0b479973046d005a1eaea299d5d536aeecb9488a16d9cbb8286338102e2d/regex-2026.6.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e86e91a2664f44c3a4e363a7d78fb17c27d5046882e30ea5a877f5e89b28d2ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5b/5b/d65adfbd02f32212431bca1f06d1e2eb763a20b12978b454bafaf23dacb7/regex-2026.6.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4dfd1331c49233998d84fc5f1f4436cf7a435a7655f6cf0f490229bb5c7254e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fc/09/2103686defaf9a0a31c1663782359d5b45f42524c64cca681f5481e44a5e/regex-2026.6.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cadea12805a1bce0b091c302b814207be26fb60a9c0e7f9ad2f9e21790a429fe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/5a/b57593c0aa23ed269ec332fbcf07852abcb6b746e811d9464e0d09b4e25f/regex-2026.6.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f2c1682b67ad5d2376498f2a5a2a8f782fa2e4a06d0465b5e357799806e8a20" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/59/c36e756ad29bf14d7b6c6d7138952476b21f6160286cedb98ac13481c993/regex-2026.6.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:64e142eb55e84868087da1375d7c36ff97d55010951849f515322a91d5fef1b4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/66/49808aea0da9649c300139360708fb91b7144be1f962fcebf96755fde948/regex-2026.6.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:abb4daabe7be63273787a62dfd6164dadf8f7a63fbec3d2730e5e5e7126d858c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/c5/52bbd436cf2200decdf48825fa38363eaaeebb77011ea9928a1ef9e0b9f2/regex-2026.6.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec2b2ad00ab8c16a2798cc8db80c53c4d5b8b3a2441f6cbaef06625f5ca25854" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/c3/0390b66e3019497143fe768b3ba567b64d8b24f3812d09506deb86f4a0f0/regex-2026.6.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bfc9677982c914d9085b8e1c3b3ae6e88f139fb56531c2416d6c8f338093c22b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/fd/ab5b03653a244975069fed93d73f4f5f7484c03a84cedb238292510d7182/regex-2026.6.28-cp313-cp313-win32.whl", hash = "sha256:bf54bc693fc4e0530e666ba5ec4bcba14dbe8f66b7cfc15c27317d1a6e40b9a5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/55/21022f7d3143210ae8d4ff905c45306237b657375cc0b97883f49db3d423/regex-2026.6.28-cp313-cp313-win_amd64.whl", hash = "sha256:e128feaf65bf3d9eb91bec92322a8f7e4835e9c798f3e9ea4b69f4def85620e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/99/7f664804f1aef924542b0b233996b78b3e4d0a52d9951358aac99f129f51/regex-2026.6.28-cp313-cp313-win_arm64.whl", hash = "sha256:695873e0ea8d3815ea9e92e2c68faf039cc450e2c0a62a31afe2049eb11be767" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/e1/9eb83518e159d719fd681c4932dc2aaff855ce72451e1d05d69466f25a96/regex-2026.6.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:189dbf9fc4252d9f1352bf4bd1bef885edb6cc4b7341df202a65f821aaa3891c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/e2/e259c5f2f7be269d0e2fb54275c1fa6a13fb47019f389c3f3ae457447825/regex-2026.6.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9277a4c6503390aa39cb4483b87ec0384faee0850a23b5cea33d008b5d8d83f1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8d/4e/9bdf444014d22b045d0c82ca114fac7e07a597b5b5331b7c4ce6328426e2/regex-2026.6.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17eddca4e8ea9af0b5739314776cdf0172a49731ab61f2e1ea66e066ddd46c97" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/3a/f49b11e59cbfe187ace0053a460bd72a0169b8cd52e7db9421a074ce7a43/regex-2026.6.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4466b8641e00c697aab5a73150150d2b2ea96b131c595691f42031abafd9f4d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2f/fb/ad04c39e149bf8b6cf357df5fff78341733ec366780a00c803a36735818c/regex-2026.6.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cfcd4b0bdcf768c498415c170d1ed2a25a99bf0b65fa253bbd02f68ceba6475" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/64/0e5ba31c11eb8ef7aac19a690c1211fc9aa9990caf09565785ebb0081b9a/regex-2026.6.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:80c7adf1ef647f6b1e8aa2ca280e517174cd08bdf7a2e412cdfb68bd6a0917cb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/11/75/6b78df2b858c2fcbbc4858fdc3f2975cf2703be374b2842db7d2c32591a7/regex-2026.6.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a043f5770e82283a22aed4cefef1a4e0f9dd8fd7184cb6ce0ad2e579e2134a9e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/01/ecfe665a3694d5eda9f3ec686c856438ada0943947b6005e90556a1e2cdf/regex-2026.6.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3bd630a8dba06b55254ea5ee862194edab52ec783100d2ef1cd15a9c512fee27" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/0a/88f9cd88ff1e82881605c4ffd62d77ee67d051232cfe6f8e9a64b86cf0e8/regex-2026.6.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b77207e3cee13086f1906a6a2a12b41244c577e8ad9370d4b35ae1d548d354f3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/97/601483732f93275482ceb9fed57813dfed7c47d3a019db6ec4a3bb6e23e0/regex-2026.6.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:6de82c268e5d101ee9e3ffd869924aa9a371e3a21e752cf4fa17b6ce50d219f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/ed/385c2a0351b994a693453c1d1a6e9af9eb35db3c9460d76b5078acd70c62/regex-2026.6.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b15859e3908544fb99cf47341dcf0bfd089147d258c4c4d8a29e5b087f8085cb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/06/bc/bbf4a5b3b29770d7f307d3c28b5b1bca0105b0cb424be0a4eb1339bc92cf/regex-2026.6.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:c91487a917edd48a1ea646fdf60d7936d304f0e686fa7ea8326e47efca51d816" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/28/26/51d74fff82f682819979249f8d700267108ba5dc4eb284b0e11b9c85e4b3/regex-2026.6.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4ac65f3e3a99fd8f3a4a74e7a6610acd1ce9dfe9b8a03d346a4922380d68aeb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/3e/6be10cefdc813533fe604dbf5d3c77d2638e7ee658b2749ebadc113b6b2e/regex-2026.6.28-cp313-cp313t-win32.whl", hash = "sha256:3f6316f258bc7e6c9c2acbe9954947bbd397a81be3742a637a555f1855d6618d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3c/3c/32cda905ea1a6eeeb798291c294d8ec66ee0efe0cdba28b061e248b1d396/regex-2026.6.28-cp313-cp313t-win_amd64.whl", hash = "sha256:1484bdd6fba28422df9b5ebb04055b2e1b680e8e4f08490bb21ff0f3cc50d0ab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ea/b9/69f4e5cd6fbe0bb420cb2dbae441ca118f2495bdda522a74da75aa9829e7/regex-2026.6.28-cp313-cp313t-win_arm64.whl", hash = "sha256:3f15020f0b69cafe57baa067ff65b29acef68ff6b1670a53bef1ca11d708e02d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3b/fb/fad3b810a5bb1e09b9e5d6913fc6ba88cab738fdf283196827a3c59a4c10/regex-2026.6.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:f7c032b0c8a73739ff8ff1aaf30c281fa19c17bf7f1543256c8507390db7807c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/52/b8c79d12276d93e90e707e939b396034c04980caf1235312ef790f8e11fc/regex-2026.6.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f6710f512c57b84f127a23d0f59560a03b64136eff419ae1be5ab557577fe5e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/23/d2/6a911f18279daa8d7bb8b20d771ddb6ef31fabd35f5921f9d3ba21640e80/regex-2026.6.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0013958f427bd82509a186b9ff206d66cb8d60a81fc797a4c717afd18c5b0ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/22/ad1955c47c669291a05804d53d7071cc0732dfdf166857be38003cedc2d1/regex-2026.6.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f06cdcd6421f8e194ad312ea608020381250df9b8a57661c1b57e9e5273878" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/67/a83159ff8703ab4d0c2cf99e76ebf289b7b4a501623241d09f88f3614f80/regex-2026.6.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec9689392f7494ff4e3f8e7e8522f9158f11023f337eaaf04a64542fc45bbf26" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/09/7bff2d6dbbd77421b3274aa51db1c887381cbc5b6eda93598c3e882ea345/regex-2026.6.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aa084684e6d2078bf6139e374d1fc2af5ddc1ac7122759a2db716d68169f6fd0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/44/ae59c3826e7ba492e56795cdf74ea2a7b5b7c5ea116afb79ee4956a5dff1/regex-2026.6.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40455e6840dc4e96a6fe50f4cedc957de2752c954d91e789812be55d49be199a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/19/6fd033d2ab00f35d445aaeaf3307c1e721424dcbfd48f6f65c857cb939cf/regex-2026.6.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:530b5c223b9ca5dd8370ac502e080aee0e4ded32be987c6564b425fb5523d581" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/9d/99730f26df4938049ab1e652ca75e967b4c6739444e18d9707bfdb8af20c/regex-2026.6.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e0ed273ecd1a89be84466c1749bfe58609cc2a32b5d5e05006c4625ba96411b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/49/105cd57162f5fc5c04cc917a1388a060cf8427e5c14353cd9044660fbf4d/regex-2026.6.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ab0d5344311fc8e8667078942056c3b9c9b4a4b1cc99f2eb8a5af54554f4acc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/a5/788245a95b69018f58bff2f4fd27d007cacaea088cdb390979743f1b2571/regex-2026.6.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:eacb79625323d9f7e7925366b917f492b8356fad58f5dc4fa12ff8c21d8f4ca9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/01/292065a39a004b05e67a337b18213670a7cb919d6856ac2d7df7f1a10dbb/regex-2026.6.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20f4d87702702aa1d572721e146f301660c50eef6fd6cb596e48a22b0ace17db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/9e/a93d865db0e13483ae1a01d81e2ce16d4a7fe2f9b9fe4aac4cc08590b136/regex-2026.6.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e693940a3b9e6d6e4dc2a54ecaa74b74934f77af1ef95f518a74261ef7cc1bc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/0c/38b1685ad4017d78efbc8fa7dbbf96d8113b53750c8aa2d3609defd46605/regex-2026.6.28-cp314-cp314-win32.whl", hash = "sha256:234a51e20ebc18ab83b2c0600cf28f2e884560a0e00f743878f0b7d8e7c4cf03" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/50/e19f261ff9ba9b50722a529e09b1743ecf65eb348be99d0fd2cd7fcede1c/regex-2026.6.28-cp314-cp314-win_amd64.whl", hash = "sha256:7b15c437bc4604f03ceb3f8d37eae2f8930e320e1bc556b259848c639d9eec1a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/36/b8/c9e68f3a9e33be73f20990b2c065b144ff2d0aa242608a950d8c4f3b56e8/regex-2026.6.28-cp314-cp314-win_arm64.whl", hash = "sha256:c6e6f790d01380a74ad564f216c533b86504afb61bf66f2b2e11e7f1a3e287a7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/e6/21c425a37880c650d007c4171c6a80325446d830d85f5fbf335e7205b1e7/regex-2026.6.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3527a72adcbe9e3600f1553b497d397c1a371d227580d41d96c3c5964109b65c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/07/50/6647a7ccf5ffff995ba955a0b7d766440f4e58ce1666549c8ee998f2b972/regex-2026.6.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a644f6408692812f5ead82519eed680e08d5d546fddbd9f7d9514e3c73899aa5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/dc/a3e141a4eaf125e50f63105570c01fa477c06ac5259dcfa95e9b90760e84/regex-2026.6.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8e2fae6bb883648346f84db270dc9aafc29d8e895f62b88a75ccc83b09519820" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/ee/2ac1a6b9f167f8ff69f5a789938cc103b60cff41b24a6990daced8b88e34/regex-2026.6.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debe623e09cee97ef9404575e936c610aac9bb08358c5099aaef14644a6871f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/7b/9a5505ee92180bcae300b1018b9ff3d3c19962436e66f2505f255e9fde35/regex-2026.6.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc579c91fb4605773483a8d940b136bcc5b854fff44fa14a1572a038f46563f1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/4d/d61a702a9f9d1bd29b22cbef1aed6d477baa961232a7eb4d91b7775b0b3e/regex-2026.6.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7c42be203d84ecf7d487ff23f8a61ef0eb0534fa0fc317a2fce8c065d20618f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d4/60/1308066f5966b65fbb6905b99ba37e9f1cd753dd0ac08485f8257334ee92/regex-2026.6.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8184b4e2fdaf9cdfe77e38f15a4d9dc149168c9c29eb0ea17c5481d3bb80546" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bd/5c/57ce2cb8d714ee0b7f11c7ee4cfe2af66df2b90f147feadcb538609a3a02/regex-2026.6.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:697f103104f5872d64078d8eeac59979960be8ee76115a2d3f31096312e2a400" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/fd/1d5350d3a8a327bff0fccacb911732baf7b5b6f5529c0e3fa602a23e7dad/regex-2026.6.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:714d2b1aa29beef0ddfcdc72ad0771c05326551a8bb0680b0ddf74bfaad87387" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/79/3c9e4f8a0306e030ad5a43bbbc01625fb28d58a813bc52d42fd1cc63fb2e/regex-2026.6.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0f09f62e450cc2f113018cc8412aeea3a120a04e1ca7e801a0d441583f9a3b06" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/12/f747de475b54f4709efb24dd0fbc8467c64cec91f5db0d047b079646ee78/regex-2026.6.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:731ea12d5aeb2577eaef2393d6428b995f76eb35f68a89e03e15a97719d1de19" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/3c/f02f860e0500c1b2d61a79dec7e214b37fb9656281dcddc92397edf96678/regex-2026.6.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:51e952c8783eabd4706d0f63922f219bcfc1bef9b8cb35941c0d1a0396578858" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/6c/28b3fa222513484be9dee26b7222bda109056c43ea28aa2314262ca48816/regex-2026.6.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43248fe4c0ab8fbb223588a0795b11268940072c97bba30ea8f9b49d8cdfde34" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/f0/8f86cf1a1fd85c5ab0c503c9fe4607ad4ad48978b2d8b435d94465e134c7/regex-2026.6.28-cp314-cp314t-win32.whl", hash = "sha256:fc1eddc25ad23c0f1344ab280d961ac595ead48292d7c779497975942373f493" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0f/de/f8613c03b36786ddef2c930d28f9bcae861fcd541cc9203a870956cf1e83/regex-2026.6.28-cp314-cp314t-win_amd64.whl", hash = "sha256:ede8d8e53b6dde0a50f7eca902f0af76d87ab02a55aba7542da68ae3e5dfe83d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/f3/f5ec86839bbabe33b6dee649b62ff9a445d43de6b0ad780cf6b83c56f61e/regex-2026.6.28-cp314-cp314t-win_arm64.whl", hash = "sha256:4da6f6a72f8700b97a1a765e837fb7d5750bfd9f13acea7bae498f573e3a70a8" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca" }, +] + +[[package]] +name = "scantree" +version = "0.0.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathspec" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/e4/40998faefc72ba1ddeb640a44fba92935353525dba110488806da8339c0b/scantree-0.0.4.tar.gz", hash = "sha256:15bd5cb24483b04db2c70653604e8ea3522e98087db7e38ab8482f053984c0ac" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/ce/828467ddfa0d2fe473673026442d2032d552a168e42cfbf25fd0e5264e0c/scantree-0.0.4-py3-none-any.whl", hash = "sha256:7616ab65aa6b7f16fcf8e6fa1d9afaa99a27ab72bba05c61b691853b96763174" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" }, +] + +[[package]] +name = "shortuuid" +version = "1.0.13" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6" }, +] + +[[package]] +name = "storage3" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/30/fee43d523d3f680a833a4aae5bf8094de0b9031b0c2bddb3e0bc6e829e1b/storage3-2.31.0.tar.gz", hash = "sha256:d2161e2ea650dc115a1787c30e09b118365589ac772f4dd8643e3a503ecfc667" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2f/b2/60d86a3a99ae743e8a00a6df912f85269b3bc882ba217b8ce1690881c669/storage3-2.31.0-py3-none-any.whl", hash = "sha256:4bf46e8bea320743179a6beafdc7531c5242495e00e0cc22af7c7a9d69d4ed84" }, +] + +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659" }, +] + +[[package]] +name = "supabase" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "httpx" }, + { name = "postgrest" }, + { name = "realtime" }, + { name = "storage3" }, + { name = "supabase-auth" }, + { name = "supabase-functions" }, + { name = "yarl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/8e/54a2f950629689b1613434a61fc3bff5f92f84ba6b20213f5b2add05c1bb/supabase-2.31.0.tar.gz", hash = "sha256:3467b09d00482b9a0138235bdbde7a350426f93cf2a1342372eaddfc669f1206" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/06/5e6f4bf89dedadf81f893832115c1866da1aa093142c9989c2818780dae3/supabase-2.31.0-py3-none-any.whl", hash = "sha256:25f2a99207a75f2d9377e2332783b4389cf56b02cbebdaf0c1743112dcbb704e" }, +] + +[[package]] +name = "supabase-auth" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/8a/408689cf39820f0d46d2731d6747ff94dbefc87ae977b4b5c4066da5b070/supabase_auth-2.31.0.tar.gz", hash = "sha256:0945b33fa96239c76dc8eaf96d7d2c94991950d24b4cfe4a5c2da9aa5e909663" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/5e/22f3b0546bb1f0985f06fb1ebf5f6405a3b1bb7a5db01142c26cf148e988/supabase_auth-2.31.0-py3-none-any.whl", hash = "sha256:5e9c8b4ecdee6af04dbcb06455ce78cb15674806fcb6b425170455307d70b0ee" }, +] + +[[package]] +name = "supabase-functions" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "strenum" }, + { name = "yarl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/84/5d/61c2446ed26a57fa5543f9c270a731320911569202340d15341f72cdba7c/supabase_functions-2.31.0.tar.gz", hash = "sha256:4ad027b3ae3bd28b31233339f4db1da6965affd3546f655b421baf40cee2690f" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/90/79/1a8162ce7d705381a4f2668c0da68e097b103c1aa701418a31da52905c7b/supabase_functions-2.31.0-py3-none-any.whl", hash = "sha256:3fdc4c4766152bfda63bdd0e286fc8a06f50e1280711fae4a1dfc9b7e9ebabc6" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55" }, +] + +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f" }, +] + +[[package]] +name = "xxhash" +version = "3.8.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/ed/07e560876a4458987511461187b285071f53cde49dd5b25cd8c51091522b/xxhash-3.8.0.tar.gz", hash = "sha256:d72b2204f37840b0f16f34192c09b994b97bd25823d723d47a1eddfacf06eb43" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/17/2e/4b7c3ab28b7a54ac17eae7e02471c49609d6fc5900856a455feeb847a2a3/xxhash-3.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fc4bd14f873cd0b420f6f1ff5b5cd0dbfeb05b044a11bb9345bcbbf9749636e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/e4/09eea3e1bba6a59d64599cb8fba39f2a0872d06e85420eae989a4da61a9d/xxhash-3.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31904979198e913239cb61b49f5b849696aeb3b03340da815d1491ec74dcc602" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/01/59/688bbae31e4e2d6d6eb92acbd3837c0e44ff8c7d435e6da922844ff6efda/xxhash-3.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7338ad13f2b273a1ef0ea97b2db0a059fdb3a1a29298bfa145937c0e4152d341" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/de/71484ce0dab2fa4a475705d1ebc37a17ff02d40e5df6767b3255cc53120e/xxhash-3.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54e80e803cb34c8a1d278b491e543af40a588d288589c3e6becc991d5328b46b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/f9/1ac88f02e7df7898541490260b21f2b7f7bd2b233038a0cbd3a3b1bffdc2/xxhash-3.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:353953ea18f5c3fbdd13936fb536aacfb47d5bc06eef0919b1a355df61f7cc31" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/49/7ea1f128d2fe948ed679020f97a0896cdc6c975da5cc69b53a4a9c4a5def/xxhash-3.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d761f983a315630eff18c2fec7360c6b6946f82748026e779336eb8141ef3eba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/da/7d237278dfa1c48722c31010c84a328a317b8885429c8cb6ae4a8fa3e3db/xxhash-3.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3786a9beb9a3b76241cb7db5f5388b460682c12204236389e3221963fc626a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9b/5f/980fda82620a07d80026b4df371cbca12fca0fd94d7087c4ec5d898da76f/xxhash-3.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c94f5a9a775f36cc522fa2a7e8e2cec512e252d2ac056759f753dc68a79ffc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/71/efa37bc3e91e1c801972bcef99eab877fcbd17ec10aca16c550ee2951107/xxhash-3.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:55ce59f9af37ac861947b43ea3ce7b294b5de77a1234b558d0f07ffad0197624" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/48/19e40320044dc7051e8446505f18557d5661853b87a8770ad399325bb3c8/xxhash-3.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3afa1422a32c7c8e79ad5121dc21eaa5cee9e9e67bffca3f15d15d220d371908" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d3/0d/588499f4d7cd064864ada7adfb9e8785f88a988f1332ed4c1be73d249c15/xxhash-3.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:551fda694938be910529452a89175137c58b4739e41fadff3c047e24b1d74a3b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/18/fb2ad593572a33d1b6864b33047b8ca7269273a3c56107b5fd33e0b9c8fb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512eb937c9457e6057e230e005c4709dd2ab63a5989f854d69f31db905750a62" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/63/9e/b880f9ed61b73492e24bb962d76aeb63f18ccb895f0edfb52e20d45ed6f2/xxhash-3.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4931ea93840f750a908efebaf23c71004feacc1a4649ef601b96d400a505c9a9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3f/89/fc682f93e54e486fc338b26a7d6d0d5cb0ab366269273c2608ac62b51afb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2fd4b60e8d9fc3923f39079f185b3425e6d76636fcb66d82a33dd7eba7c30f2f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/71/a4b4122afb2d17ad69e0922cfeddb5ad5c25b02f37eed3dd3819d42e5f55/xxhash-3.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1da00075f1605794298878cb587f7533329693e2a0c45bbd25d6353644add675" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/e5/ed3930f5dc90f4b1bab5ac3be099e8b2e81c1262d85e4adb5f2758e30d23/xxhash-3.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba73801c87d44fa37b2a5feab3004f0a654506027bf032ceb154d94bb74ea772" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/ae/128ea5794387ca54bb4084566db20dbdfc9c21cb17b67d3fcb403927b5ba/xxhash-3.8.0-cp312-cp312-win32.whl", hash = "sha256:0b0836dee6022e22ba516ebfa8f76c6e4bda08d6c166c553e40867bac89e4a54" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/04/a6c182dc566c88e8d1a497d22cc4ffdcfcc0a9fa80325efa6cd4b9002c54/xxhash-3.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3bc2a09b98b8f85c75208cd2b2d2aecf40c77ecb2d72f6bf9757db51a98d3499" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/b5/aeda4e79f962c8d58ec60cb20a5abfe91c9f7d62e626f69f6659bc0bd0c4/xxhash-3.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:208e6a8b93426896d803224e9fabe26f8b9c651e8381a80b1fa31812faa091e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/1f/96f43c5c7c7c4d44721f8d2e5d74698c667a30283c4b10a7e50a56804ee3/xxhash-3.8.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:36434c1d1b0a4729df1fa26ab11bffed1ba52666c0beb605c98a995b470cd143" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1c/d9/7d5d6af4876c6481f2e0acb2dda64dd5209574bf7ba1ad4f6af7a1f8d473/xxhash-3.8.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:a5e6497cefcb2d67f1745c66df9718a99112583af6cc2b70da0312a2eb939f1e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/32/ff/66fed439d78c5a09a1491a85af29bf8923b516530116731a9ac6b14dee2b/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5b00b82f1be708da9404fefd658cf5cf3be5ee3be2aae4bfe3b874255badd342" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/56/b8/9fae0399281095f8aca1f32b21947b3c3c75ad6021b255c5c6e4b11d3866/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38b0cb0ab7f283413b7cace2bf710d7cf8f702ea82cbc683908691d52028a89b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/a4/e53d162c74a8a2950dc063969914387b0680da4c7c20ad17744ec03a3b0a/xxhash-3.8.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:084312171a9798dea85e924b2674f5e1a44933050a1ea1cb1c6b1364e004c66c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/f5/e12397e3f2c4917b6572e103a3277cd27cc56330e304bba61d195d7e5224/xxhash-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6a1a9e845bd3bbc57d9356819e0d198fe23282e0576b398a6282a0f8fdc75aef" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/70/80/c053dc51af5c942229689a0e9cb66fdc999bbd840f645e761f5ab73cbb17/xxhash-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ffbde09743ebaf8957b8426948fbe85eab5e5de0d29eec407fcff5a2812a3cc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/a3/294171b67dfe770e1293edcf2a3f7e41302cdb8aefb258585312191b3ffe/xxhash-3.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a6dee3952c2b6e82e7f1dbc5dbc6167f9c84126851def7926e32827c2816169c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/c3/d141bfdeca785c8c680abf867d4b52a5e64a55d90df242c3141a3e58c4b2/xxhash-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf8ff8e12416c9fa05b43c7509b9332d6ffc4090413c4e7a1dee8599763b6d59" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/09/5a/aeaf35143a6f3d44db73298e861405bdd9c9dacaedfc369cb43d9fd65282/xxhash-3.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cebbb322df4d97d8ef2704f49ed2f6f21f6702fafa0dc0c2a6ae70e904205689" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/3e/f8ca782bb34f99693faab70a7989bcc84f62ffe93c9a4cca464a33507a4b/xxhash-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a8d08707b4100ebce598fc59fadf04b42d79b855818d6994f8f0fffd1df8edb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d4/fa/ddbee4ff1542c2e88e72269a5a6bd18c3f26a80c2514e0918f5d1f3e9ec5/xxhash-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cf5427602dda15d8ce3c6d870d29bf07d43975f59c9d6d3f7f6f93a901b28b12" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/f5/a680d48dddab37ab2fd9189ca03f775e29e3627122e30790816d7eb365af/xxhash-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97d7bd715ea5050b6c9638b52c62adf3055b648ef6eee6892a4cd9697b530191" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/b1/7ac129b74981c07f1ff9c649f204465e86f83f9f29b2ebdc70d91514c365/xxhash-3.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cd25bbbab37d898f6e5a90905ce6ae2c1f8bd6668c07cef406fb3e8c8c570dd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/e6/43e673411249dd63f6cd974523a1b32fad75cf5453e363bc8f44af215fb9/xxhash-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3e30e5c057f483c3c53a11b53eba091a737cb19dfead36c8b23bf5beb4a169cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/95/87f8baf41f63130f3637104b7a610f82b20106332fc6e289c8dbf7955d0e/xxhash-3.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:07dd44d992ebd456752bc25b1c42cd172d94bd8cb24049300449ad0716081c3a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/c9/3369b497cd1f926b930c52fd2400606f177790d887b49f9e86bddcc24562/xxhash-3.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3118600a3102d4707dc1c485dbc3acbbbf37819069ad3e7854e77b923745d76b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/34/c8/03dceb86a8128858ac105bd6e282d62b3db6fd421a79bd8a9f6b8cdc47a7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ed37b0c95d8fb3fbaad5e13cc0a9727eb8739d1d54b2adef28108c250cada3a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/a5/ebd43eeb1af1dd8f0201943688b20958e99d3f6eb36481fb8c37b55ef139/xxhash-3.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bb043da412e478e7b1db3407051124b85b133803794d3809ad6d92870b304fc7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/24/c873e41a3c00dacc385c8ff08c007723f6a528922c1cea7fd9684e86dae7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:196fc132683d9311a0bdce8388ee52bfa07fdc1987cc428a27956e47ccd7b50d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/1b/c671272fe28f70574e3c574d58465f26460154bcc68876121872afa1c14d/xxhash-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfb5411af3b77c75e99db100aa15c5ba623c85d72c565e4d7a0ed1a986ff766e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/43/b45a52f795812cb769b6ac159e69b605d18b1c067749e63dcac159e90064/xxhash-3.8.0-cp313-cp313-win32.whl", hash = "sha256:6d1d6179e26830c6690fac63f76d372f69714b977e12ca9c42188a60f51c59f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/42/2bd70e4eec25dc5990652979d708d4d7c999793d7d5af5d0e48ab4374dc1/xxhash-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c92427a56a12f4d5c7bb26dbb9e9a4658c313ecb6c2f1dca349902e3822df07" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/c8/2fe61edb6144183cf094035a8c5354c65a073127acf6379655ed1e705b70/xxhash-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:9fc8453642c1c6d38b4fbac8901c2452ce1fa88b27f003bfee6703cbfae9bd63" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/b8/81d17a993b9a4750ba426ce966421681bb4b8e82a460cd346756491b8cc2/xxhash-3.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:efcacb644a915f010dc477447b045e5dcde1afaa40d16b2f0f8e7cd99c9e1635" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/3b/f5a368e3273440b3ea58fbd3f0b08c19f552b25ca59f43f5732ca96d2126/xxhash-3.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d1e0dbc510cff94c5efbcc2b82c28b41519fad09b5b1f9f3d99c63e3940e49a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/ab/f424359c91c55f564fbbe4e454a126eb522471109f67376f20ad19c5e663/xxhash-3.8.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ff19d016a41c90d1f519005887191896b6da1274e1d5d48b347e17eb798ffc5a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ac/c2/434579ef9235123b6c9bfa89c5614e0001e988613b91557b24aa326d9faa/xxhash-3.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aafc3eab99c50508852e34307e9565933bf128cad084cac7d2471b7ab1743de0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/6c/3c0c917331ca3c71f826cedce2127f230624e2b49b992472dd5e9e72101c/xxhash-3.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e521368ed79ae6c4d31e1e417726643c49d7d6e286f4fdabf9a8330ed8a8ff7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/f3/a8bb98d3307c67e88be9642dff52854c3de3f488f95989b60ff69c8dcc42/xxhash-3.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a0127688d116ec0c225e7e1f744e3f206de2b8822ffeb31a9ab5cc6384f92c5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f7/73/fab69a2e5b6353dde643209fe9b6adf4fbd64c888e531deffc476bfb2635/xxhash-3.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:22c0b17da2f9fea0f8836538512249871b359141616bad44c58d238b5f011f40" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/5b/ba34099b5278097ec9c68c0b740719813553bfd11ca17e7353de6d2a41e3/xxhash-3.8.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d49465646b1a5e3b1729c5f636e05676a2fb52e203e3b22a5411c416c4c5302" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/0c/90aba4708a37fe752b324a7cbf10058eaa33e892cdd62751ff17a5137b93/xxhash-3.8.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2853dea1e30ed00ca87dd87d76da5da063d302b823b3fb80ccd18421de0f251" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/46/42e349e2d3017b2688f4cb301742c37c438e77963e3fef711edce2fc5c65/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:82f0102a2a3760287b7cd7f9e0a30edd4c3b18762ed1a242208d43c8e2bcf30b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ee/15/741b947ae3c768e82018c46846f8616f6aa9b5042649f318a1a6897defe3/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b8414a66a7524596d841cad5dc1adab6ce76848db5ab2b83db911fbdab1417af" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/b4/a9db84c9458fc8f53eaf0051377d1e9eecd9f330fb1225640027417a309d/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dbaa73df10414ea1e41b98691a9d8241d4c47ad8d02c726587a3cda05278e53" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/92/60a868cd34851746d0b0d95dced0f42867c7c00606f6e5dba85b70b232ce/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:43fc9aaba10ab4267c90793601f60d35c3c9caa1544eceb483618a71ad9ce7da" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/6a/168ca46a4679c32aae9246caa1fddf35981d6304487e45e992b3d4530324/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ec5eb3d28fbb9802c6d2526f772133a06c91d6f03756fcc67c834b642ffdd51d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/18/0b/13646b348c07679c818791ab2d35415db5cb20f3bc77daaa255909a401b4/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:2b77c301b644cd9b4d0749a3291081ec2048a6bef7fe0487c993bbba3efb9ce0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/9a/3d244b2acf6bbd86a363817ee09084b4684e8e11840663e19869e9e0d952/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d7ece11a132325353890a144c30119073617a1299c593ca29b96c315b07e1edd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/c7/143410d026a6e0d86dc69037ec2a3b8db810a54e7f443b340ac17612be2e/xxhash-3.8.0-cp313-cp313t-win32.whl", hash = "sha256:b21db84df7b9d54d9e4195a964243c1b32d745c6fbc0cfcfffee1d4bd297196a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/db/2240b0638161637b2f310231748a7a6a06c79fb43a3adb34c96f359762bf/xxhash-3.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0643b7d9f598f6da6f1f6b899f4358250d0fb853242e2d712cbde27bf5a99d29" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/d8/52038e4fa5baf4f00654a225516168d02908edfec7ca104fbefc58af394f/xxhash-3.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4bbacf2e938526969f8ab3334d4ac3da14ea059e1dfd1339a92f9091467e750f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/ef/a09907aa28bdcdf6810d5c26656b154c60c0f06bb8db8442a1192d9c227a/xxhash-3.8.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:557e2a7cc0b6a634cf9c8e5c975d96b7da796fdeb1824569d760cf0f25b6f33f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/4d/d991ff77bc489c2231025e64e570502156d573c7bff69c917589cc307089/xxhash-3.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:dad744d1613cbfddb844dad93adbffbd51c3e9f53ceea9568f7c3b94bedc19a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cc/0e/553eab001f1e274da73da074968cdc8be8cacfb318937ab9871b8e1909cb/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:953f29b22c04b123cf3cd2e08bccde3a73184aeda5a1038e0054cb3355644120" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/d5/d0f4dbe7b4d9ce0125f16e45ec0be5e04f6a172edb4e2fa551c4f2eb5d7a/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:aa699e0253ceffecf41cae858d0a11f2439d6874a0890b556387bffe11dc1c08" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2e/2f/b332c7bede6a676343f2c9c8dea233c8c82753eaeda6f7a2c321d8c58ca3/xxhash-3.8.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e232c82466babc13e956d53aa84d0149660ed6886bc195248bb4d03bf2eca301" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/5b/2bf3c9e61c7cf8f53bce937af45e22b72bb1f224d5afb20352beba0d628d/xxhash-3.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f75fd1c6a5028f345cd4a8c52f4774d2e5b7809fa58111c60a5502b528914a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/b6/e88521f5736c181b89bfb7ab756f0ca658a8a1ecece7277b75e167717614/xxhash-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b49d7e09b211a1ad658dbe2dbf6561eb92f2e6926bd1101e2d023178371f2d6f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ed/a2/fba440739fa5f86d2c28738c202e88d3dd063290c8bbb20e183c5334456a/xxhash-3.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ceb702bc8e56b7f1f1413d42aa294045b9a0e4c9888e07edc5cd153e8c4c948f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/1c/4a1639efec16416695d6c7bc6b224d3f607e0b8cbe2409fa81081a849d1c/xxhash-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f3c96e06bdb122e8cc84f5c7088579f3102b828efd62e9dc964a9d17c7b89e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/d1/8ce471f8d6752384f972fd5f6363f2e8d8b867a89fbd724c6dbd91d2bb98/xxhash-3.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:415a8d06ac9bea36b1e06b603a347e0f62401042a97d7bfccec8ae2da12ad784" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/77/400a281683fd39c54e2ac497fa67bdf886baaadb8c0ba58f7e1ea1d7692e/xxhash-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f5ccdd2deb5dce31201cc0eec94388cce97e681429073db50903fab0a0a8a0d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/aa/a6/edda651cfa0ba8e921791e93468fae655b63894d89730fcbfe46704f0d0a/xxhash-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a6cf81bc699d3a5ebfcf2fdb2a7bd2e096708d7de193f6f322944a02ba00953" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dd/da/50f764ec6a93d3961fce294567e41bfca0e66d168deed354a3dc90ebeba6/xxhash-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4d12a04d7ffc0359f0eadc4535a53cab113044c8d2f262c7e9a56950a5ed50e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/49/9fe4ed5aac6f38629cc83b34f84748b83ad8295a578ec6a49d8bf896cafb/xxhash-3.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d209373fcb66138c652cf843385ee60866e50158a7869bbbf8b322d9a822b765" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/83/f5/1147e03c0553ed22bbae9ce47503c37ee0c5f95592aae10f339c25f61de9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b88a3fe28277811e599efa6e1c96abce8a77d60dd79c94da7a9b5c377c172b7b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b8/d8/92daf66c1966c84da5c97a06ced1480208d3a3bd465cb0630565ec00d1b9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5d5a888a5ef997cb35f1aad346eb861cd87ecfe24f5e25d5aa4c9fd1bd3950c2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bd/c0/080c1a92972667e183c04b03f33c877f8ec61cfa3570e61731077286648d/xxhash-3.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:de2836e0329c01555957a603dcd113c337c577081153d691c12a51c5be3282b0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d9/d5/cbc4e5b2bee10c94cba05b5bb2b8033e7ef44ae742583fdafcd9188e33ed/xxhash-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4bc74eedb0dd5827b3be748bacf9fdb50004037a3e16c7ddb5defae2682cef71" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/f7/09679b00e192b741b65c230440c4f7e6df3251a9ad427a518ddf262ec71a/xxhash-3.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c571b03d59e339b010dc84f15a6f1cff80212f3a3116c2a71e2303c95065b1f6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/1b/f43ec36e8c6a20c77be0bcca23f0b133ed8a0312681500d1676eebd71924/xxhash-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:87626acdd6e2d762c588a4ffe94258c5ef34fb6049a4a3b25019bdb7f9267a9b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/45/2e/a3e3a779c5e4789daf975e05cc1c7f11bae724a03855120029d4592c8e63/xxhash-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:076d8a4fb290af952826922aa42a46bfc64caa31662ce4e2925a445d0e6ce57f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/da/1c1e078ac290afff304a541a2a60965beb369ad65b4f30ec93ea1e0b7210/xxhash-3.8.0-cp314-cp314-win32.whl", hash = "sha256:52f8c7c9833d947e60df830671f6eca810d7c667051243985a561c79f1a3d545" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/7a/d455cb83d5e3c94046234294fb5dbbe5da600d1bbdf76b9527756920cce9/xxhash-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:4fbfcb7dd307e23189a71050f6e27746926590330f37d5fd2ffcb8ea78de1f42" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/8f/1b14471f617bc96edbb9566099a162d918a981381c398114726cc600b76c/xxhash-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:ecef1e65b4715c7326002073763fe94cc44c756a0698508abb915ab3d6be6e3d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/11/8d/51ad2f9f784121c8057ef1ba36362f58d4595cbcad16322941f5b73eb53d/xxhash-3.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:02ed856a765cb6e006168595d9455ac8c3c4d60cc04cd47a158a1ac677d68f0f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/14/175c573ae4fac48bf21a82e5b9ceec75d64c520c51ca08de3105de539438/xxhash-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eec30461a7b457611098ba7ab09363e36c8b2645b4687fb6f3d405bb646e3410" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/08/f83efabd350a50c31c851b88891e318a6f07bdbf40a43d0f7bb6cedade7f/xxhash-3.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b471744912d1ce5dd6d3975b7525e77518359ebf3aa1bd7d501e199f5ae488ea" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/78/2b6d12da9cf572c84d93b88ecbf9bf6539a7c5219bde128b214396b97c8b/xxhash-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3748d71202bf3f279e77cb8b273b6d0f29d1bcaefb6ce6cb03b95f358863ba37" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/0a/755eeb1882634983b24e6375a95ed233228dc48f0ef12655388bf3c7eeaf/xxhash-3.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b3bf59ea94b2a23b0f992769804ab9401d5cdcd9df0062fe2cd78a491ae8851" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/77/f2/09b1231cad17c314e51664c4a004c919108ec59aba10f9a28fa061e7b8be/xxhash-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f061aa5379eba249e9367b179515571e632be6d1b6f55ac139e6fe3d08463c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/24/de756d55547953494eb6775aea92e258035647b3ecb8547618cd549001e1/xxhash-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:680d70896a61fc920cc717a0a8fe8a9fb5858c563184666e31874caa54a16d9e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/63/b8147633e32f98ef2b4bb0dfca82f0f63e2b02ff179f20664af64c4216a7/xxhash-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14973fbdee136588e57447401b521f466a42faca41eecdf35123c73103512ca8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/37/ba051d8f0380d3cf845b23ba058a17d32025846463eb6bf885887fc8effe/xxhash-3.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96c6bca2486cdc58b125966817a92a6abe6ef1fab86b2f8798a7e93488782540" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/6f/36e0a27dd27ffa3f7b521650cbcd52a00fb86b71343ffadb642374e8263c/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b1109ae238e932d8482f9cb568b56a405cc73bc7a36b837844087f1298dd218" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/73/2663dbf4c09386a9dcc8a94d7a14b4609ed4bad8180ced5b848e60a9b660/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1da5db0863400eade7c5a31969754d1392189f26b4105f6631da2c6c7ea3bccc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/58/f3ce1bc3bb3971191f6521273ddae98d3c610bcefbbed5327c3b3627c12f/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c61b5a0f21ace5e886f177cce43826d85a7c84e35a9e17cb6d1b4ac0b7a7d833" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/51/835706a36cdc00e5b638fba9b22218b3d40d23a7677c923feca8a3f55b98/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1db4f27835a450c7e729bc9330c6e702113711cea1f873d646e3a31fe96a9732" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/47/b0b62caa3caee58ab9de8969f66aef1c3729886f3ff60e173fda3f2762be/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4788a470f946df34383abc6cd345088c13f897a5ee580c4cdd12b1d32ad218ef" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/c4/60e6d18a0e131c7af622374af9deede15d3c47d8e5e7221933481b57b319/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3b6dfa83096cb1e54d082acebaf67f0c42667c56dc48ba536a76cac08d46391e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/12/9f/c9627daa052be39a932d0e17c6bf6a9041d2cde3afacbded9196acf70261/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:57ec0ba5299a9a7df376063c139f5826ff0c89b438703939af3d252c31ca96a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/38/92916e008a84c1f1a9aef82e4363cdc478a722ff69e59c6afbf93d3d1fda/xxhash-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:d9a61f23b999baeb84102aba767b1b3e94958eab94e6c11b08927e7dc4200795" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/7c/e413bc75121d9628bf023b2ed251411ca3a447cf00cd9aa3438ab17f6c67/xxhash-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:61069b260fff84116235bb93845f319284dc6b42527c215af59264f4c2ee3468" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f6/eb/21a96e218375bd8b6ecd6d07cf60c8ff1a046e93cdedc3cf7bc3309edf7b/xxhash-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:73cecd431b4f572d38fcf1a7fe85b30eb987778ef9e7a70bc9ffcf2d64810e6f" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f" }, +] diff --git a/benchmarks/harbor-buzz-orchestra/tests/conftest.py b/benchmarks/harbor-buzz-orchestra/tests/conftest.py new file mode 100644 index 0000000000..bd0bcaf2dd --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/conftest.py @@ -0,0 +1,52 @@ +from typing import Any +import pytest + + +@pytest.fixture +def manifest_data() -> dict[str, Any]: + digest = "a" * 64 + generation = { + "temperature": 0, + "max_output_tokens": 4096, + "context_window_tokens": 200000, + } + return { + "schema_version": "1", + "condition": "O", + "roster": [ + { + "id": "opus-orchestrator", + "kind": "orchestrator", + "role": "orchestrator", + "count": 1, + "endpoint": "databricks/opus", + "model_revision": "2026-07-01", + "prompt": {"path": "prompts/orchestrator.md", "sha256": digest}, + "generation": generation, + }, + { + "id": "qwen-workers", + "kind": "worker", + "role": "fast-worker", + "count": 4, + "concurrency": 2, + "endpoint": "databricks/qwen", + "model_revision": "revision-42", + "prompt": {"path": "prompts/qwen.md", "sha256": digest}, + "generation": {**generation, "context_window_tokens": 32768}, + }, + ], + "prices": { + "databricks/opus": { + "input_per_million_usd": 5, + "cached_input_per_million_usd": 0.5, + "output_per_million_usd": 25, + }, + "databricks/qwen": { + "input_per_million_usd": 0.1, + "cached_input_per_million_usd": 0.01, + "output_per_million_usd": 0.2, + }, + }, + "trial_budget": {"timeout_seconds": 3600, "max_cost_usd": 5}, + } diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py new file mode 100644 index 0000000000..62c6047ab3 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py @@ -0,0 +1,169 @@ +from types import SimpleNamespace +from uuid import uuid4 +import pytest +from harbor.models.agent.context import AgentContext +from harbor_buzz_orchestra import ( + AgentCredential, + BuzzOrchestraAgent, + RuntimeResult, + TrialHandle, +) + +pytestmark = pytest.mark.asyncio + + +async def test_agent_credential_carries_closed_relay_attestation(): + credential = AgentCredential( + agent_id="orchestrator-1", + role="orchestrator", + nostr_secret_key="11" * 32, + nostr_pubkey="22" * 32, + nostr_auth_tag='["auth","owner","conditions","signature"]', + llm_endpoint="https://example.databricks.com/serving-endpoints/opus", + llm_api_key="attributed-key", + ) + + assert credential.nostr_auth_tag.startswith('["auth"') + + +class Provisioner: + def __init__(self): + self.healthchecked = False + self.created = None + self.torn_down = None + + def healthcheck(self): + self.healthchecked = True + + def create_trial(self, run_id, trial_id, manifest, channel_label=None): + self.created = (run_id, trial_id, manifest, channel_label) + return TrialHandle( + run_id, + trial_id, + manifest.sha256, + "ws://relay", + "channel-1", + (), + user=AgentCredential( + agent_id="user", + role="user", + nostr_secret_key="s", + nostr_pubkey="p", + nostr_auth_tag="[]", + llm_endpoint="", + llm_api_key="", + ), + ) + + def teardown(self, handle): + self.torn_down = handle + + +class Runtime: + def __init__(self, error=None): + self.called = None + self.error = error + + async def run(self, **kwargs): + self.called = kwargs + if self.error: + raise self.error + return RuntimeResult(10, 2, 3, 0.25, {"receipt_status": "pending"}) + + +async def test_agent_lifecycle_and_context(tmp_path, manifest_data): + provisioner, runtime, context_id = Provisioner(), Runtime(), uuid4() + environment = SimpleNamespace( + context_id=context_id, environment_name="hello-world" + ) + agent = BuzzOrchestraAgent( + logs_dir=tmp_path, + manifest=manifest_data, + provisioner=provisioner, + runtime=runtime, + run_id="run-1", + ) + agent.context_id = context_id + context = AgentContext() + await agent.setup(environment) + await agent.run("solve it", environment, context) + assert provisioner.healthchecked + assert provisioner.created[:2] == ("run-1", str(context_id)) + # The task short name labels the trial channel for spectator GUIs. + assert provisioner.created[3] == "hello-world" + assert provisioner.torn_down.channel_id == "channel-1" + assert runtime.called["instruction"] == "solve it" + assert ( + context.n_input_tokens, + context.n_cache_tokens, + context.n_output_tokens, + context.cost_usd, + ) == (10, 2, 3, 0.25) + assert context.metadata["manifest_sha256"] == agent.manifest.sha256 + assert context.metadata["trial_id"] == str(context_id) + + +async def test_teardown_runs_when_runtime_fails(tmp_path, manifest_data): + provisioner, runtime, context_id = ( + Provisioner(), + Runtime(RuntimeError("runtime failed")), + uuid4(), + ) + environment = SimpleNamespace(context_id=context_id) + agent = BuzzOrchestraAgent( + logs_dir=tmp_path, + manifest=manifest_data, + provisioner=provisioner, + runtime=runtime, + ) + agent.context_id = context_id + with pytest.raises(RuntimeError, match="runtime failed"): + await agent.run("solve it", environment, AgentContext()) + assert provisioner.torn_down.channel_id == "channel-1" + + +async def test_missing_integrations_fail_explicitly(tmp_path, manifest_data): + agent = BuzzOrchestraAgent(logs_dir=tmp_path, manifest=manifest_data) + with pytest.raises(RuntimeError, match="M1 wiring is incomplete"): + await agent.run("solve it", SimpleNamespace(context_id=uuid4()), AgentContext()) + + +async def test_cli_runtime_construction_from_json(tmp_path, manifest_data): + endpoint_path = tmp_path / "endpoints.json" + endpoint_path.write_text( + '{"frontier/rev":{"provider":"anthropic",' + '"api_key_env":"ANTHROPIC_API_KEY"},' + '"worker/rev":{"provider":"openai",' + '"api_key_env":"OPENAI_API_KEY"}}' + ) + agent = BuzzOrchestraAgent( + logs_dir=tmp_path / "logs", + manifest=manifest_data, + artifact_root=tmp_path, + endpoint_config=endpoint_path, + buzz_acp_binary="/pinned/buzz-acp", + buzz_agent_binary="/pinned/buzz-agent", + buzz_dev_mcp_binary="/pinned/buzz-dev-mcp", + buzz_cli_binary="/pinned/buzz", + ) + assert agent.runtime.artifact_root == tmp_path + assert agent.runtime.endpoints["frontier/rev"].provider == "anthropic" + assert agent.runtime.buzz_acp_binary == "/pinned/buzz-acp" + assert agent.runtime.buzz_agent_binary == "/pinned/buzz-agent" + assert agent.runtime.buzz_dev_mcp_binary == "/pinned/buzz-dev-mcp" + assert agent.runtime.buzz_cli_binary == "/pinned/buzz" + + +async def test_cli_construction_requires_complete_pairs(tmp_path, manifest_data): + with pytest.raises(ValueError, match="artifact_root"): + BuzzOrchestraAgent( + logs_dir=tmp_path, + manifest=manifest_data, + endpoint_config={}, + ) + with pytest.raises(ValueError, match="provisioner_factory"): + BuzzOrchestraAgent( + logs_dir=tmp_path, + manifest=manifest_data, + provisioner_config={}, + ) diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py new file mode 100644 index 0000000000..8669fe980c --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py @@ -0,0 +1,426 @@ +"""The container runtime must launch the production stack, unmodified.""" + +import hashlib +import json +import re +from pathlib import Path + +import pytest +from harbor.environments.base import ExecResult + +from harbor_buzz_orchestra.manifest import ExperimentManifest +from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle +from harbor_buzz_orchestra.container_runtime import ( + REMOTE_BIN, + REMOTE_LOGS, + BuzzContainerRuntime, + EndpointLaunchConfig, + RuntimeLaunchError, +) + + +def write_manifest(tmp_path: Path) -> ExperimentManifest: + prompt = tmp_path / "prompt.md" + prompt.write_text("prompt", encoding="utf-8") + digest = hashlib.sha256(prompt.read_bytes()).hexdigest() + roster_entry = { + "count": 1, + "model_revision": "r1", + "prompt": {"path": "prompt.md", "sha256": digest}, + "generation": {"max_output_tokens": 100, "context_window_tokens": 1000}, + } + return ExperimentManifest.load( + { + "condition": "test", + "roster": [ + {"id": "orch", "kind": "orchestrator", "role": "lead", + "endpoint": "orch-model", **roster_entry}, + {"id": "worker", "kind": "worker", "role": "implementer", + "endpoint": "worker-model", **roster_entry}, + ], + "prices": { + name: { + "input_per_million_usd": 0, + "cached_input_per_million_usd": 0, + "output_per_million_usd": 0, + } + for name in ("orch-model", "worker-model") + }, + "trial_budget": {"timeout_seconds": 30}, + } + ) + + +def credential(agent_id, role, endpoint): + return AgentCredential( + agent_id=agent_id, + role=role, + nostr_secret_key=f"secret-{agent_id}", + nostr_pubkey=f"pubkey-{agent_id}", + nostr_auth_tag="[]", + llm_endpoint=endpoint, + llm_api_key=f"key-{agent_id}", + ) + + +def user_credential(): + return AgentCredential( + agent_id="user", + role="user", + nostr_secret_key="secret-user", + nostr_pubkey="pubkey-user", + nostr_auth_tag="[]", + llm_endpoint="", + llm_api_key="", + ) + + +def trial_handle(credentials, user_relay_url=""): + return TrialHandle( + run_id="run", + trial_id="trial", + manifest_hash="hash", + relay_ws_url="ws://host.docker.internal:3600", + channel_id="channel", + credentials=credentials, + user=user_credential(), + user_relay_url=user_relay_url, + ) + + +def runtime(tmp_path, **kwargs): + return BuzzContainerRuntime( + logs_dir=tmp_path / "logs", + artifact_root=tmp_path, + endpoints={ + "orch-model": EndpointLaunchConfig("anthropic", "ANTHROPIC_API_KEY"), + "worker-model": EndpointLaunchConfig("anthropic", "ANTHROPIC_API_KEY"), + }, + **kwargs, + ) + + +class Environment: + """Records execs/uploads; scripted stdout per command substring.""" + + def __init__(self, responses=None): + self.commands = [] + self.uploads = [] + self.responses = responses or {} + + async def exec(self, command, env=None, **kwargs): + self.commands.append((command, env)) + for needle, result in self.responses.items(): + if needle in command: + return result + return ExecResult(stdout="", stderr="", return_code=0) + + async def upload_file(self, source, target): + self.uploads.append((str(source), target)) + + async def download_dir(self, source, target): + pass + + +def test_maps_credentials_exactly_and_rejects_role_mismatch(tmp_path): + manifest = write_manifest(tmp_path) + credentials = ( + credential("orch-1", "orchestrator", "orch-model"), + credential("worker-1", "worker", "worker-model"), + ) + assert set(runtime(tmp_path)._classes_by_agent_id(manifest, credentials)) == { + "orch-1", + "worker-1", + } + bad = (credential("worker-1", "orchestrator", "worker-model"),) + with pytest.raises(RuntimeLaunchError, match="role"): + runtime(tmp_path)._classes_by_agent_id(manifest, bad) + + +def test_prompt_hash_and_identity_override_are_fail_closed(tmp_path): + manifest = write_manifest(tmp_path) + prompt_ref = manifest.roster[0].prompt + runtime(tmp_path)._verify_artifact(tmp_path / prompt_ref.path, prompt_ref.sha256) + (tmp_path / prompt_ref.path).write_text("changed", encoding="utf-8") + with pytest.raises(RuntimeLaunchError, match="hash mismatch"): + runtime(tmp_path)._verify_artifact( + tmp_path / prompt_ref.path, prompt_ref.sha256 + ) + + endpoint = EndpointLaunchConfig( + "anthropic", "ANTHROPIC_API_KEY", {"BUZZ_ACP_MCP_COMMAND": "evil"} + ) + with pytest.raises(RuntimeLaunchError, match="identity"): + runtime(tmp_path)._reject_identity_overrides(endpoint) + + +def test_user_relay_url_prefers_host_view(tmp_path): + rt = runtime(tmp_path) + # v1.2 handles carry the host view for the trial user explicitly. + assert ( + rt._user_relay_url(trial_handle((), user_relay_url="http://localhost:3600")) + == "http://localhost:3600" + ) + # pre-v1.2 handles fall back to deriving http from the agents' ws view. + assert ( + rt._user_relay_url(trial_handle(())) + == "http://host.docker.internal:3600" + ) + with pytest.raises(RuntimeLaunchError, match="ws://"): + rt._cli_relay_url("http://relay") + + +async def test_install_stack_uploads_the_pinned_stack(tmp_path): + binaries = {} + for name in ("buzz-acp", "buzz-agent", "buzz-dev-mcp"): + path = tmp_path / name + path.write_text("#!binary") + binaries[name] = str(path) + rt = runtime( + tmp_path, + buzz_acp_binary=binaries["buzz-acp"], + buzz_agent_binary=binaries["buzz-agent"], + buzz_dev_mcp_binary=binaries["buzz-dev-mcp"], + ) + environment = Environment() + await rt._install_stack(environment) + assert {target for _, target in environment.uploads} == { + f"{REMOTE_BIN}/buzz-acp", + f"{REMOTE_BIN}/buzz-agent", + f"{REMOTE_BIN}/buzz-dev-mcp", + } + assert any("chmod 0755" in cmd for cmd, _ in environment.commands) + + +async def test_install_stack_requires_binaries_on_disk(tmp_path): + rt = runtime(tmp_path, buzz_acp_binary=str(tmp_path / "missing")) + with pytest.raises(RuntimeLaunchError, match="binary not found"): + await rt._install_stack(Environment()) + + +async def test_forwarder_bridges_the_canonical_relay_address(tmp_path): + from harbor_buzz_orchestra.container_runtime import FORWARDER + + forwarder = tmp_path / "relay-forwarder" + forwarder.write_text("ELF") + rt = runtime( + tmp_path, + relay_gateway="host.docker.internal:3600", + forwarder_binary=str(forwarder), + ) + trial = TrialHandle( + run_id="run", trial_id="trial", manifest_hash="hash", + relay_ws_url="ws://localhost:3600", channel_id="channel", + credentials=(), user=user_credential(), + ) + environment = Environment( + responses={ + FORWARDER: ExecResult(stdout="99\n", stderr="", return_code=0), + "cat ": ExecResult( + stdout="forwarding 127.0.0.1:3600 -> host.docker.internal:3600", + stderr="", return_code=0, + ), + } + ) + agent = await rt._start_forwarder(environment, trial) + assert agent is not None and agent.pid == 99 + launch = next(cmd for cmd, _ in environment.commands if FORWARDER in cmd) + # Listens on the canonical loopback (host-header bound), targets the gateway. + assert "127.0.0.1:3600" in launch + assert "host.docker.internal:3600" in launch + + # No gateway configured: the relay is reachable directly, no forwarder. + assert await runtime(tmp_path)._start_forwarder(Environment(), trial) is None + with pytest.raises(RuntimeLaunchError, match="ws://"): + rt._ws_authority("http://relay") + + +@pytest.mark.parametrize(("configured", "expected"), [(None, "32"), (7, "7")]) +async def test_launch_wires_the_desktop_environment(tmp_path, configured, expected): + manifest = write_manifest(tmp_path) + agent_class = manifest.roster[0] + if configured is not None: + agent_class = agent_class.model_copy( + update={ + "budget": agent_class.budget.model_copy( + update={"max_calls": configured} + ) + } + ) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = trial_handle((orch,)) + environment = Environment( + responses={"buzz-acp": ExecResult(stdout="4242\n", stderr="", return_code=0)} + ) + agent = await runtime(tmp_path)._launch_agent( + environment=environment, + trial=trial, + credential=orch, + agent_class=agent_class, + trial_dir=tmp_path, + ) + assert agent.pid == 4242 + command, env = environment.commands[-1] + assert f"{REMOTE_BIN}/buzz-acp" in command + # The real product wiring: acp spawns buzz-agent, which gets buzz-dev-mcp. + assert env["BUZZ_ACP_AGENT_COMMAND"] == f"{REMOTE_BIN}/buzz-agent" + assert env["BUZZ_ACP_MCP_COMMAND"] == f"{REMOTE_BIN}/buzz-dev-mcp" + assert env["BUZZ_RELAY_URL"] == trial.relay_ws_url + assert env["BUZZ_PRIVATE_KEY"] == orch.nostr_secret_key + assert env["NOSTR_PRIVATE_KEY"] == orch.nostr_secret_key + assert env["BUZZ_AGENT_NO_HINTS"] == "1" + assert env["BUZZ_AGENT_MAX_ROUNDS"] == expected + assert env["BUZZ_ACP_SYSTEM_PROMPT_FILE"].endswith("orch-1.system-prompt.md") + # The composed prompt was uploaded into the container. + assert any( + target == env["BUZZ_ACP_SYSTEM_PROMPT_FILE"] + for _, target in environment.uploads + ) + + +def test_runtime_rejects_unbounded_agent_rounds(tmp_path): + with pytest.raises(ValueError, match="positive"): + runtime(tmp_path, max_agent_rounds=0) + with pytest.raises(ValueError, match="positive"): + runtime(tmp_path, readiness_timeout_seconds=0) + + +async def test_wait_for_agents_ready_requires_every_channel_subscription(tmp_path): + rt = runtime(tmp_path, poll_seconds=0) + logs = {"orch-1": "", "worker-1": ""} + + class ReadyEnvironment(Environment): + polls = 0 + + async def exec(self, command, env=None, **kwargs): + if command.startswith("cat "): + agent_id = re.search(r"([\w-]+)\.stdout\.log", command).group(1) + return ExecResult( + stdout=logs[agent_id], stderr="", return_code=0 + ) + return ExecResult(stdout="", stderr="", return_code=0) + + from harbor_buzz_orchestra.container_runtime import _Agent + + agents = [ + _Agent( + credential(agent_id, "worker", "worker-model"), + pid=1, + stdout_log=f"{REMOTE_LOGS}/{agent_id}.stdout.log", + stderr_log=f"{REMOTE_LOGS}/{agent_id}.stderr.log", + ) + for agent_id in logs + ] + logs["orch-1"] = "subscribed to channel trial-channel\n" + logs["worker-1"] = "subscribed to channel trial-channel\n" + await rt._wait_for_agents_ready(ReadyEnvironment(), agents, "trial-channel") + + logs["worker-1"] = "" + rt_timeout = runtime(tmp_path, poll_seconds=0, readiness_timeout_seconds=0.01) + with pytest.raises(RuntimeLaunchError, match="worker-1"): + await rt_timeout._wait_for_agents_ready( + ReadyEnvironment(), agents, "trial-channel" + ) + + +async def test_dead_agent_processes_fail_the_trial(tmp_path): + from harbor_buzz_orchestra.container_runtime import _Agent + + agents = [ + _Agent(credential("worker-1", "worker", "worker-model"), 7, "o", "e") + ] + environment = Environment( + responses={ + "kill -0": ExecResult(stdout="DEAD:worker-1\n", stderr="", return_code=0) + } + ) + with pytest.raises(RuntimeLaunchError, match="worker-1"): + await runtime(tmp_path)._raise_for_dead_agents(environment, agents) + + +@pytest.mark.parametrize( + ("condition", "return_code", "raises"), + [ + ("M1-hello-world", 0, False), + ("M1-hello-world", 1, True), + ("other", 1, False), + ], +) +async def test_m1_output_probe_matches_grader_and_is_condition_scoped( + tmp_path, condition, return_code, raises +): + manifest = write_manifest(tmp_path).model_copy(update={"condition": condition}) + environment = Environment( + responses={ + "hello.txt": ExecResult(stdout="", stderr="", return_code=return_code) + } + ) + if raises: + with pytest.raises(RuntimeLaunchError, match="/app/hello.txt"): + await runtime(tmp_path)._verify_m1_output(environment, manifest) + else: + await runtime(tmp_path)._verify_m1_output(environment, manifest) + probed = [cmd for cmd, _ in environment.commands if "hello.txt" in cmd] + assert bool(probed) == (condition == "M1-hello-world") + + +async def test_wait_for_done_requires_orchestrator_authorship(tmp_path, monkeypatch): + rt = runtime(tmp_path, poll_seconds=0) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = trial_handle((orch,)) + rounds = iter( + [ + [{"id": "1", "pubkey": "someone-else", "content": "DONE: fake"}], + [{"id": "2", "pubkey": orch.nostr_pubkey, "content": "DONE: real"}], + ] + ) + observers = [] + + async def buzz_json(credential, *args, **kwargs): + observers.append(credential.agent_id) + return next(rounds) + + monkeypatch.setattr(rt, "_buzz_json", buzz_json) + result = await rt._wait_for_done(Environment(), orch, trial, []) + assert json.dumps(result).find("real") > 0 + # observation happens as the trial user, never as an agent identity + assert set(observers) == {"user"} + + +def test_composed_system_prompt_carries_persona_and_team_roster(tmp_path): + rt = runtime(tmp_path) + orch = credential("orch-1", "orchestrator", "orch-model") + worker_1 = credential("worker-1", "worker", "worker-model") + worker_2 = credential("worker-2", "worker", "worker-model") + trial = trial_handle((orch, worker_1, worker_2)) + persona = tmp_path / "persona.md" + persona.write_text("# Persona body\n", encoding="utf-8") + + path = rt._compose_system_prompt( + trial_dir=tmp_path, + trial=trial, + credential=orch, + persona_path=persona, + ) + + composed = path.read_text(encoding="utf-8") + assert composed.startswith("# Persona body\n") + assert "You are `orch-1` (pubkey `pubkey-orch-1`)" in composed + assert f"channel `{trial.channel_id}`" in composed + assert "user `user` (pubkey `pubkey-user`)" in composed + # roster lists teammates, never the agent itself + assert "| worker-1 | worker | `pubkey-worker-1` |" in composed + assert "| worker-2 | worker | `pubkey-worker-2` |" in composed + assert "| orch-1 " not in composed + assert path.stat().st_mode & 0o777 == 0o600 + + +async def test_stop_agents_sweeps_the_uploaded_stack(tmp_path): + from harbor_buzz_orchestra.container_runtime import _Agent + + environment = Environment() + agents = [_Agent(credential("orch-1", "orchestrator", "orch-model"), 1, "o", "e")] + await BuzzContainerRuntime._stop_agents(environment, agents) + sweeps = [cmd for cmd, _ in environment.commands if REMOTE_BIN in cmd] + assert len(sweeps) == 2 + assert "kill -TERM" in sweeps[0] and "kill -KILL" in sweeps[1] diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py b/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py new file mode 100644 index 0000000000..36533db3bf --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py @@ -0,0 +1,47 @@ +import copy +import pytest +import yaml +from harbor_buzz_orchestra import ExperimentManifest, ManifestError + + +def test_hash_is_independent_of_mapping_and_yaml_key_order(tmp_path, manifest_data): + first = ExperimentManifest.load(manifest_data) + path = tmp_path / "manifest.yaml" + path.write_text( + yaml.safe_dump( + dict(reversed(list(copy.deepcopy(manifest_data).items()))), sort_keys=False + ) + ) + second = ExperimentManifest.load(path) + assert first.canonical_bytes() == second.canonical_bytes() + assert first.sha256 == second.sha256 + assert len(first.sha256) == 64 + + +def test_hash_changes_when_staffing_changes(manifest_data): + first = ExperimentManifest.load(manifest_data) + changed = copy.deepcopy(manifest_data) + changed["roster"][1]["count"] = 3 + assert ExperimentManifest.load(changed).sha256 != first.sha256 + + +@pytest.mark.parametrize( + ("mutation", "match"), + [ + (lambda data: data.update({"unknown": True}), "Extra inputs"), + (lambda data: data["roster"].pop(0), "exactly one orchestrator"), + (lambda data: data["prices"].pop("databricks/qwen"), "prices missing"), + (lambda data: data["roster"][1].update({"concurrency": 5}), "concurrency"), + ], +) +def test_invalid_manifest_is_rejected(manifest_data, mutation, match): + mutation(manifest_data) + with pytest.raises(ManifestError, match=match): + ExperimentManifest.load(manifest_data) + + +def test_non_mapping_document_is_rejected(tmp_path): + path = tmp_path / "manifest.yaml" + path.write_text("- not\n- a\n- mapping\n") + with pytest.raises(ManifestError, match="root must be a mapping"): + ExperimentManifest.load(path) diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py b/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py new file mode 100644 index 0000000000..ed048ee5d9 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py @@ -0,0 +1,125 @@ +"""The leaderboard runner must emit only leaderboard-legal harbor settings.""" + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest +import yaml + +_SCRIPT = Path(__file__).parent.parent / "scripts" / "run_leaderboard.py" +_spec = importlib.util.spec_from_file_location("run_leaderboard", _SCRIPT) +run_leaderboard = importlib.util.module_from_spec(_spec) +sys.modules["run_leaderboard"] = run_leaderboard +_spec.loader.exec_module(run_leaderboard) + +FORBIDDEN_FLAGS = ( + "--timeout-multiplier", + "--agent-timeout-multiplier", + "--verifier-timeout-multiplier", + "--agent-setup-timeout-multiplier", + "--environment-build-timeout-multiplier", + "--override-cpus", + "--override-memory", + "--override-storage", + "--override-gpus", +) + + +@pytest.fixture +def binaries(tmp_path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + found = {} + for name in run_leaderboard.BINARIES: + path = bin_dir / name + path.write_text("#!/bin/sh\n") + found[name] = path + return found + + +@pytest.fixture +def agent_binaries(tmp_path): + bin_dir = tmp_path / "linux-bin" + bin_dir.mkdir() + for name in run_leaderboard.AGENT_BINARIES: + (bin_dir / name).write_text("ELF") + return run_leaderboard.find_agent_binaries(bin_dir) + + +@pytest.fixture +def args(tmp_path, binaries, agent_binaries): + manifest = tmp_path / "team.yaml" + manifest.write_text( + yaml.safe_dump( + { + "condition": "unit-test", + "roster": [ + {"id": "orch", "endpoint": "frontier"}, + {"id": "worker", "endpoint": "fast", "count": 2}, + ], + } + ) + ) + endpoints = tmp_path / "endpoints.json" + endpoints.write_text(json.dumps({"frontier": {"provider": "anthropic"}})) + provisioner = tmp_path / "provisioner.json" + provisioner.write_text("{}") + return run_leaderboard.parse_args( + [ + "--dataset", + "terminal-bench/terminal-bench-2-1", + "--attempts", + "5", + "--manifest", + str(manifest), + "--endpoint-config", + str(endpoints), + "--provisioner-config", + str(provisioner), + "--agent-bin-dir", + str(next(iter(agent_binaries.values())).parent), + "--job-name", + "unit-test-job", + ] + ) + + +def test_command_uses_standard_settings_only(args, binaries, agent_binaries): + command = run_leaderboard.build_command(args, binaries, agent_binaries) + assert command[:2] == ["harbor", "run"] + assert command.count("-k") == 1 + assert command[command.index("-k") + 1] == "5" + for flag in FORBIDDEN_FLAGS: + assert flag not in command + # The full production stack rides in as agent kwargs. + kwargs = [command[i + 1] for i, p in enumerate(command) if p == "--agent-kwarg"] + assert any(k.startswith("buzz_acp_binary=") for k in kwargs) + assert any(k.startswith("buzz_agent_binary=") for k in kwargs) + assert any(k.startswith("buzz_dev_mcp_binary=") for k in kwargs) + + +def test_agent_binaries_must_exist(tmp_path): + with pytest.raises(SystemExit, match="buzz-dev-mcp"): + run_leaderboard.find_agent_binaries(tmp_path) + + +def test_forbidden_flags_are_not_accepted(tmp_path): + for flag in FORBIDDEN_FLAGS: + with pytest.raises(SystemExit): + run_leaderboard.parse_args( + ["--dataset", "d", "--attempts", "5", + "--agent-bin-dir", str(tmp_path), flag, "1"] + ) + + +def test_metadata_template_matches_harbor_schema(args, tmp_path): + from harbor.leaderboard.metadata import load_metadata + + path = run_leaderboard.write_metadata_template(args, tmp_path) + loaded = load_metadata(path) + assert loaded["agent_org_display_name"] == "Block" + assert [m["model_name"] for m in loaded["models"]] == ["frontier", "fast"] + assert loaded["models"][0]["model_org_display_name"] == "Anthropic" + assert loaded["models"][1]["model_provider"] == "FILL_ME" diff --git a/benchmarks/harbor-buzz-orchestra/uv.lock b/benchmarks/harbor-buzz-orchestra/uv.lock new file mode 100644 index 0000000000..05072d81a8 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/uv.lock @@ -0,0 +1,2496 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001" }, +] + +[[package]] +name = "datasets" +version = "5.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "dill" }, + { name = "filelock" }, + { name = "fsspec", extra = ["http"] }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "multiprocess" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pyarrow" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "xxhash" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d9/85/ce4f780c32f7e36d71257f1c27e8ba898ebe379cb54f211f5f2013f2c219/datasets-5.0.0.tar.gz", hash = "sha256:83dbbbdb07a33b82192b8c419deb18739b138ee2ce1a322d55ce6b100954ec1a" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/66/73034ad30b59f13439b75e620989dacba4c047256e358ba7c2e9ec98ea22/datasets-5.0.0-py3-none-any.whl", hash = "sha256:7dd34927a0fd7046e98aad5cb9430e699c373238a15befa7b9bf22b991a7fee6" }, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d" }, +] + +[[package]] +name = "dirhash" +version = "0.5.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "scantree" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1d/70/49f93897f3a4f7ab5f20a854ebc91aad47854e9fb2cd169e3a4452fa3f5e/dirhash-0.5.0.tar.gz", hash = "sha256:e60760f0ab2e935d8cb088923ea2c6492398dca42cec785df778985fd4cd5386" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/1f/c8bf92552b7f0a13b9f12b85e3de8df6d9814240e0f8ce8f37433df028b3/dirhash-0.5.0-py3-none-any.whl", hash = "sha256:523dfd6b058c64f45b31604376926c6e2bd2ea301d0df23095d4055674e38b09" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, +] + +[[package]] +name = "fastapi" +version = "0.138.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/a9/9f8f7e00195c29836e9bf58bbbaf579e29878b8a67851efff93d9b6d4eb7/fastapi-0.138.2.tar.gz", hash = "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615" }, +] + +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" }, +] + +[[package]] +name = "fsspec" +version = "2026.4.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2" }, +] + +[package.optional-dependencies] +http = [ + { name = "aiohttp" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd" }, +] + +[[package]] +name = "harbor" +version = "0.16.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "datasets" }, + { name = "dirhash" }, + { name = "fastapi" }, + { name = "filelock" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "litellm" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "shortuuid" }, + { name = "supabase" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "typer" }, + { name = "uvicorn" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/37/46/49008d2a07f98ce0eb333366546cac8451d6e80c8068ac7687711c56c862/harbor-0.16.1.tar.gz", hash = "sha256:115c39ad76b3d1dbfd82ff51af904adf22acac240c90b12eb34e8cb7d46f8cc4" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/f3/5abb440eff288c5fef89079e49b873e825a4b563b874b849dc728d551fab/harbor-0.16.1-py3-none-any.whl", hash = "sha256:26264bb011baf25f44d5264ee74e1a0882eb936f9883f8618bce1573878c3f66" }, +] + +[[package]] +name = "harbor-buzz-orchestra" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "harbor" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "harbor", specifier = ">=0.16.1,<0.18" }, + { name = "pydantic", specifier = ">=2.11" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" }, +] +provides-extras = ["dev"] + +[[package]] +name = "hf-xet" +version = "1.5.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/24/5e0c28f80371c17d49fed004597d9d132cb75c1f6f53db2cb95f459d2312/hf_xet-1.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3474760d10e3bb6f92ff3f024fcb00c0b3e4001e9b035c7483e49a5dd17aa70f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/17/261ba565b6a4d960fb478f61fdf919c0be5824645aaf1c319eca660c1611/hf_xet-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6762d89b9e3267dfd502b29b2a327b4525f33b17e7b509a78d94e2151a30ce30" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/44/7ffdc2e184b0d41fc0f683ba3936ef669ab63cf242cf36ef50e57d683668/hf_xet-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf67e6ed10260cef62e852789dc91ebb03f382d5bdc4b1dbeb64763ea275e7d6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/63/b6/788060d5aa4d5e671f1a31bf69624c314eb2d8babab3aa562f9e5d53444e/hf_xet-1.5.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c6b6cd08ca095058780b50b8ce4d6cbf6787bcf27841705d58a9d32246e3e47a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/93/c5540cbd6b55529b7dc42f6734e88cebee21aefbea34128b66229df56c57/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1af0de8ca6f190d4294a28b88023db64a1e2d1d719cab044baf75bec569e7a9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/f3/9d8ceab30f44f36c1679b1b8683054c71a0dadc787dbf07421891742d3ca/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4f561cbbb92f80960772059864b7fb07eae879adde1b2e781ec6f86f6ac26c59" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/54/27ed9a5e2cc583b4df82f75a03a4df8dbf55f5a9fa1f47f1fadfb20dbeac/hf_xet-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e7dbb40617410f432182d918e37c12303fe6700fd6aa6c5964e30a535a4461d6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/12/ecb2fc8d45e767580e3a37faa97cb895608b614965567efb4f18cff67e27/hf_xet-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6071d5ccb4d8d2cbd5fea5cc798da4f0ba3f44e25369591c4e89a4987050e61d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.21.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/77/ce3331f40cb2d021fe9b24c46c41e72faf74493621138e5eddac12bf5e1c/huggingface_hub-1.21.0.tar.gz", hash = "sha256:a44f222cd8f2f7c2eade30b5e7a04cac984a3235fa61ea87a0a5a31db77d561f" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl", hash = "sha256:eadaa3678c512c82aea69e8675d90a184861e68de32f1105668628b4dce0e7cd" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.9.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7d/f9/97f2ca8bb3ec6e4b1d64f983ebe98b9a192faddff67fac3d6303a537e670/importlib_metadata-8.9.0-py3-none-any.whl", hash = "sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, +] + +[[package]] +name = "litellm" +version = "1.90.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/00/4187e33818d0de19fd602214ce15a6e1cfa5bf04fb50a6e59606214a92df/litellm-1.90.2.tar.gz", hash = "sha256:b536603894ba2a0ccd14a6f1ebeb5f46cc35b19d4537e8fda7bfdfc7757f19d3" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/30/3afa7b212ce1f9bb8b6fa5f58c631f437c1d7b3a025e4e4ed6b01b3d28ad/litellm-1.90.2-py3-none-any.whl", hash = "sha256:6fb46f485150cc861be62a62d670f94d33adbd26991091befeb51bcdfdad34f6" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" }, +] + +[[package]] +name = "multiprocess" +version = "0.70.19" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "dill" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/f2/e783ac7f2aeeed14e9e12801f22529cc7e6b7ab80928d6dcce4e9f00922d/multiprocess-0.70.19.tar.gz", hash = "sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/45/8004d1e6b9185c1a444d6b55ac5682acf9d98035e54386d967366035a03a/multiprocess-0.70.19-py310-none-any.whl", hash = "sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/c2/dec9722dc3474c164a0b6bcd9a7ed7da542c98af8cabce05374abab35edd/multiprocess-0.70.19-py311-none-any.whl", hash = "sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl", hash = "sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/74/d2c27e03cb84251dfe7249b8e82923643c6d48fa4883b9476b025e7dc7eb/multiprocess-0.70.19-py313-none-any.whl", hash = "sha256:8d5eb4ec5017ba2fab4e34a747c6d2c2b6fecfe9e7236e77988db91580ada952" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/61/af9115673a5870fd885247e2f1b68c4f1197737da315b520a91c757a861a/multiprocess-0.70.19-py314-none-any.whl", hash = "sha256:e8cc7fbdff15c0613f0a1f1f8744bef961b0a164c0ca29bdff53e9d2d93c5e5f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5" }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865" }, +] + +[[package]] +name = "openai" +version = "2.44.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, +] + +[[package]] +name = "postgrest" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/22/88c470d8838d2678a44e0172d061630b8837cba3fb7fb492e28f6578c309/postgrest-2.31.0.tar.gz", hash = "sha256:2f395d84b2ee34fc57622ff2f711df603e2ede625f98e5015240741888f7bd0c" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ea/3e/41909586cb148db0259e0208310067afda4cc097f4c7a779e829c1e68c46/postgrest-2.31.0-py3-none-any.whl", hash = "sha256:c2fd47c94e13ee8335111c4f03c9a24ea9766ce9d35fc3cd7330057c9e7ea0c3" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, +] + +[[package]] +name = "realtime" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/34/54a1eaaefa24db5cb12596fd74792e08efa53ed30dc5bce2c0a68ded6146/realtime-2.31.0.tar.gz", hash = "sha256:9e641cb4d77ca0fe768515f8cf9f83550c79f49ce1550a95afc2dc0e252be8c9" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/60/164246615e8b059f6d53d34648a0784260421ec98a07eb1e45160f063221/realtime-2.31.0-py3-none-any.whl", hash = "sha256:f6e494b53d6a6e80b6efcee6711c8dd40413a52e766271de1bce8ced6c36cc1d" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, +] + +[[package]] +name = "regex" +version = "2026.6.28" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/05/e4f219230e11e774a6c9987d2ab0d0c6b8573e13a17e143d0015bee710ef/regex-2026.6.28.tar.gz", hash = "sha256:3cb4b6c5cb3060cc31efdc1fbb27c25fb9b29044afd87e40601a1c4d9db54342" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/da/21/44aa415873032056c43eac21c67285deb2cf66cddb2a964c3cdc8f803efc/regex-2026.6.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:81cc5793ad33a10444445e8d29d3c73e752c8fb2e120772d70fcb6d41df40fe1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/5f/30d4116093c2128099f78b6990dfc1698fdbf3ee528f1e1c647378034c79/regex-2026.6.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e18225243250a1f7d7e5e5d883f3b96465cd79031acf5c6db902b7025f2125d9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/0e/ca20a0e0de49837e6337603a91ab77556aa27033ac5b975615d98698cfb3/regex-2026.6.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ecd1638b1c2db1f2d01c182a4b0d3e2e88b0e99910320a745c1727ee3638ddab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/50/11/c013422a7e2c59946df8ac93e792a4922c98287f2a2181341603c78a5d98/regex-2026.6.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4303ebe16b74eeb3fe2715745023266fea92fd44a23f3e7bb2fb48c7a7bbc195" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/95/1309645a0e1ee6fb91d954501da57a0b33d50ad2a9acb313702851a7054e/regex-2026.6.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56b856b70b96c381d837f609eee442a1bd320cd2159f5c294b679552fb1a7eaf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/06/491802db47c6f5e2904ffa2518ad3ac27fe6bbf5a66d73210a95cc080d47/regex-2026.6.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f74675ab76ab1d005ffba4dee308e53e89efc22be6e9f9fae5b539a3f81bdff2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5e/60/3ba57840bcc7e2367090360de0c15a5ba6ad22be89314251105f2e943f43/regex-2026.6.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90581684565a93f7258af1e5d3f41ef20d7d7c61f2a428183a342bcb65485e38" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/27/af1eb74e9a78c782b3e450b611a595e44906da8a5107e1227f4a7fd0480b/regex-2026.6.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:28f9e6c28f9b90f6f784595a33240a57e181e61b6ee3dc259b25c61e356d1aa3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/18/fdd4c883a39e3ed00d669062af1135809bfd3281bf528150849fbd68825b/regex-2026.6.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:378a71d861fc7c8806b04ac5b133d53c0e774f92f5d9663a539872d3fa2b0417" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1c/79/0aabe34b8482dcadf64355f70f96e22eba5ec6c1efb33563f89654f4061c/regex-2026.6.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc199874ecd6267a49b111052250825bfe19b5101b23b2ba80f54efa3e0994e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/2c/c973323306a27c9db7d160e9584eb7e0ece2a96224ccb0d39060558b31f9/regex-2026.6.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b916a10431494ef4b4d62c6c89cab6426af7873125b8cd6c15811bf5fc58eec8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/df/9ca3e378e352242a4cb45573a5e9162c3ee791507702a23966fa559e36b5/regex-2026.6.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2e27727fba075f1e4409416d2f537d4c30fc11f012ea507f7bd74d3e19ecb57a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/3e/3e31e255c4971f53cbce6306b5e3c76cbd3735a54f419bb3b2f194e9f68c/regex-2026.6.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:700fc6a7844bb2c4149292ac79d1df8841a00acd4d45cd32c1ebc7bcc1fd0da8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/72/01/d36561c21c3033d7eeb31d51b491916817de7861acefccc5fc9db8a5037c/regex-2026.6.28-cp312-cp312-win32.whl", hash = "sha256:03376d60b6a11aecb88a79fa2be06b40faa01c6693bc31ef69435cd4818b9463" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/59/bbbb0591f38b18c65977cd65ce64749eba1c1996c99ac04e900fc30c0dcb/regex-2026.6.28-cp312-cp312-win_amd64.whl", hash = "sha256:fbd2ded482bf99e6651992bbfcde460272724d4bbc49ef3d6b46d9312867ec84" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/06/be4f6b337d773ae5739a1bc238f97c16926e72017243735853c030f4c628/regex-2026.6.28-cp312-cp312-win_arm64.whl", hash = "sha256:37294d3d7ddb64c7e89184b2894e0f8f0a19c514bc59513d71fe692c3a8d5fc6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/53/d5c1b3cc0b5a0c985563ad6fac93d73ff2b300cb84342d89f044625d6bc7/regex-2026.6.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b295a83426e0e44e9e60fde99789e181bd26788a1890ae7fe2a24c69bb6246ca" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8d/9f/0c3503e819e91ca0e7a901a8e989ebf840ac7c7aea20b1fc7f31b6759f77/regex-2026.6.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c31665c0deb5c111557a1cac8c27bd5629e2f9e7fd5058900a03576c33b601c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/7f/cd004e13fcad23b3794a82307dfd222e6365eb7f598bd3caab148a830bff/regex-2026.6.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6bf295f2c59de77d1ea7de053607ae4dc9ceb3d57bbb6c7ec51ef4acc4ccff94" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/73/4c/293fb34586fbcdc47eac436069e9c11f71fae5dadfd4889b475d7d2e5f7a/regex-2026.6.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17c077586770f67e05bbffeba07fbee6b2b22244f4d4caf8d94e59d574befe04" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/fa/c0cd1a90b7d12d9dc155cfc8bdea8df9720988ea5b07e8fa1eccbd0ab2dd/regex-2026.6.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e6cb5a61486f9062397d2e189573b39d38ecfaed698fd9fb6e2756a8ebb8762" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/db/0b479973046d005a1eaea299d5d536aeecb9488a16d9cbb8286338102e2d/regex-2026.6.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e86e91a2664f44c3a4e363a7d78fb17c27d5046882e30ea5a877f5e89b28d2ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5b/5b/d65adfbd02f32212431bca1f06d1e2eb763a20b12978b454bafaf23dacb7/regex-2026.6.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4dfd1331c49233998d84fc5f1f4436cf7a435a7655f6cf0f490229bb5c7254e5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fc/09/2103686defaf9a0a31c1663782359d5b45f42524c64cca681f5481e44a5e/regex-2026.6.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cadea12805a1bce0b091c302b814207be26fb60a9c0e7f9ad2f9e21790a429fe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/5a/b57593c0aa23ed269ec332fbcf07852abcb6b746e811d9464e0d09b4e25f/regex-2026.6.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f2c1682b67ad5d2376498f2a5a2a8f782fa2e4a06d0465b5e357799806e8a20" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/59/c36e756ad29bf14d7b6c6d7138952476b21f6160286cedb98ac13481c993/regex-2026.6.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:64e142eb55e84868087da1375d7c36ff97d55010951849f515322a91d5fef1b4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/66/49808aea0da9649c300139360708fb91b7144be1f962fcebf96755fde948/regex-2026.6.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:abb4daabe7be63273787a62dfd6164dadf8f7a63fbec3d2730e5e5e7126d858c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/c5/52bbd436cf2200decdf48825fa38363eaaeebb77011ea9928a1ef9e0b9f2/regex-2026.6.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec2b2ad00ab8c16a2798cc8db80c53c4d5b8b3a2441f6cbaef06625f5ca25854" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f9/c3/0390b66e3019497143fe768b3ba567b64d8b24f3812d09506deb86f4a0f0/regex-2026.6.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bfc9677982c914d9085b8e1c3b3ae6e88f139fb56531c2416d6c8f338093c22b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/fd/ab5b03653a244975069fed93d73f4f5f7484c03a84cedb238292510d7182/regex-2026.6.28-cp313-cp313-win32.whl", hash = "sha256:bf54bc693fc4e0530e666ba5ec4bcba14dbe8f66b7cfc15c27317d1a6e40b9a5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/55/21022f7d3143210ae8d4ff905c45306237b657375cc0b97883f49db3d423/regex-2026.6.28-cp313-cp313-win_amd64.whl", hash = "sha256:e128feaf65bf3d9eb91bec92322a8f7e4835e9c798f3e9ea4b69f4def85620e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/99/7f664804f1aef924542b0b233996b78b3e4d0a52d9951358aac99f129f51/regex-2026.6.28-cp313-cp313-win_arm64.whl", hash = "sha256:695873e0ea8d3815ea9e92e2c68faf039cc450e2c0a62a31afe2049eb11be767" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/e1/9eb83518e159d719fd681c4932dc2aaff855ce72451e1d05d69466f25a96/regex-2026.6.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:189dbf9fc4252d9f1352bf4bd1bef885edb6cc4b7341df202a65f821aaa3891c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/e2/e259c5f2f7be269d0e2fb54275c1fa6a13fb47019f389c3f3ae457447825/regex-2026.6.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9277a4c6503390aa39cb4483b87ec0384faee0850a23b5cea33d008b5d8d83f1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8d/4e/9bdf444014d22b045d0c82ca114fac7e07a597b5b5331b7c4ce6328426e2/regex-2026.6.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17eddca4e8ea9af0b5739314776cdf0172a49731ab61f2e1ea66e066ddd46c97" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/3a/f49b11e59cbfe187ace0053a460bd72a0169b8cd52e7db9421a074ce7a43/regex-2026.6.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4466b8641e00c697aab5a73150150d2b2ea96b131c595691f42031abafd9f4d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2f/fb/ad04c39e149bf8b6cf357df5fff78341733ec366780a00c803a36735818c/regex-2026.6.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cfcd4b0bdcf768c498415c170d1ed2a25a99bf0b65fa253bbd02f68ceba6475" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/64/0e5ba31c11eb8ef7aac19a690c1211fc9aa9990caf09565785ebb0081b9a/regex-2026.6.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:80c7adf1ef647f6b1e8aa2ca280e517174cd08bdf7a2e412cdfb68bd6a0917cb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/11/75/6b78df2b858c2fcbbc4858fdc3f2975cf2703be374b2842db7d2c32591a7/regex-2026.6.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a043f5770e82283a22aed4cefef1a4e0f9dd8fd7184cb6ce0ad2e579e2134a9e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/01/ecfe665a3694d5eda9f3ec686c856438ada0943947b6005e90556a1e2cdf/regex-2026.6.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3bd630a8dba06b55254ea5ee862194edab52ec783100d2ef1cd15a9c512fee27" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b4/0a/88f9cd88ff1e82881605c4ffd62d77ee67d051232cfe6f8e9a64b86cf0e8/regex-2026.6.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b77207e3cee13086f1906a6a2a12b41244c577e8ad9370d4b35ae1d548d354f3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a8/97/601483732f93275482ceb9fed57813dfed7c47d3a019db6ec4a3bb6e23e0/regex-2026.6.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:6de82c268e5d101ee9e3ffd869924aa9a371e3a21e752cf4fa17b6ce50d219f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/ed/385c2a0351b994a693453c1d1a6e9af9eb35db3c9460d76b5078acd70c62/regex-2026.6.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b15859e3908544fb99cf47341dcf0bfd089147d258c4c4d8a29e5b087f8085cb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/06/bc/bbf4a5b3b29770d7f307d3c28b5b1bca0105b0cb424be0a4eb1339bc92cf/regex-2026.6.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:c91487a917edd48a1ea646fdf60d7936d304f0e686fa7ea8326e47efca51d816" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/28/26/51d74fff82f682819979249f8d700267108ba5dc4eb284b0e11b9c85e4b3/regex-2026.6.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4ac65f3e3a99fd8f3a4a74e7a6610acd1ce9dfe9b8a03d346a4922380d68aeb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/3e/6be10cefdc813533fe604dbf5d3c77d2638e7ee658b2749ebadc113b6b2e/regex-2026.6.28-cp313-cp313t-win32.whl", hash = "sha256:3f6316f258bc7e6c9c2acbe9954947bbd397a81be3742a637a555f1855d6618d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3c/3c/32cda905ea1a6eeeb798291c294d8ec66ee0efe0cdba28b061e248b1d396/regex-2026.6.28-cp313-cp313t-win_amd64.whl", hash = "sha256:1484bdd6fba28422df9b5ebb04055b2e1b680e8e4f08490bb21ff0f3cc50d0ab" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ea/b9/69f4e5cd6fbe0bb420cb2dbae441ca118f2495bdda522a74da75aa9829e7/regex-2026.6.28-cp313-cp313t-win_arm64.whl", hash = "sha256:3f15020f0b69cafe57baa067ff65b29acef68ff6b1670a53bef1ca11d708e02d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3b/fb/fad3b810a5bb1e09b9e5d6913fc6ba88cab738fdf283196827a3c59a4c10/regex-2026.6.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:f7c032b0c8a73739ff8ff1aaf30c281fa19c17bf7f1543256c8507390db7807c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/52/b8c79d12276d93e90e707e939b396034c04980caf1235312ef790f8e11fc/regex-2026.6.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f6710f512c57b84f127a23d0f59560a03b64136eff419ae1be5ab557577fe5e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/23/d2/6a911f18279daa8d7bb8b20d771ddb6ef31fabd35f5921f9d3ba21640e80/regex-2026.6.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0013958f427bd82509a186b9ff206d66cb8d60a81fc797a4c717afd18c5b0ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/22/ad1955c47c669291a05804d53d7071cc0732dfdf166857be38003cedc2d1/regex-2026.6.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f06cdcd6421f8e194ad312ea608020381250df9b8a57661c1b57e9e5273878" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/67/a83159ff8703ab4d0c2cf99e76ebf289b7b4a501623241d09f88f3614f80/regex-2026.6.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec9689392f7494ff4e3f8e7e8522f9158f11023f337eaaf04a64542fc45bbf26" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/09/7bff2d6dbbd77421b3274aa51db1c887381cbc5b6eda93598c3e882ea345/regex-2026.6.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aa084684e6d2078bf6139e374d1fc2af5ddc1ac7122759a2db716d68169f6fd0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/44/ae59c3826e7ba492e56795cdf74ea2a7b5b7c5ea116afb79ee4956a5dff1/regex-2026.6.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40455e6840dc4e96a6fe50f4cedc957de2752c954d91e789812be55d49be199a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/19/6fd033d2ab00f35d445aaeaf3307c1e721424dcbfd48f6f65c857cb939cf/regex-2026.6.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:530b5c223b9ca5dd8370ac502e080aee0e4ded32be987c6564b425fb5523d581" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/9d/99730f26df4938049ab1e652ca75e967b4c6739444e18d9707bfdb8af20c/regex-2026.6.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e0ed273ecd1a89be84466c1749bfe58609cc2a32b5d5e05006c4625ba96411b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/49/105cd57162f5fc5c04cc917a1388a060cf8427e5c14353cd9044660fbf4d/regex-2026.6.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ab0d5344311fc8e8667078942056c3b9c9b4a4b1cc99f2eb8a5af54554f4acc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/a5/788245a95b69018f58bff2f4fd27d007cacaea088cdb390979743f1b2571/regex-2026.6.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:eacb79625323d9f7e7925366b917f492b8356fad58f5dc4fa12ff8c21d8f4ca9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/01/292065a39a004b05e67a337b18213670a7cb919d6856ac2d7df7f1a10dbb/regex-2026.6.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20f4d87702702aa1d572721e146f301660c50eef6fd6cb596e48a22b0ace17db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/9e/a93d865db0e13483ae1a01d81e2ce16d4a7fe2f9b9fe4aac4cc08590b136/regex-2026.6.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e693940a3b9e6d6e4dc2a54ecaa74b74934f77af1ef95f518a74261ef7cc1bc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/0c/38b1685ad4017d78efbc8fa7dbbf96d8113b53750c8aa2d3609defd46605/regex-2026.6.28-cp314-cp314-win32.whl", hash = "sha256:234a51e20ebc18ab83b2c0600cf28f2e884560a0e00f743878f0b7d8e7c4cf03" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/50/e19f261ff9ba9b50722a529e09b1743ecf65eb348be99d0fd2cd7fcede1c/regex-2026.6.28-cp314-cp314-win_amd64.whl", hash = "sha256:7b15c437bc4604f03ceb3f8d37eae2f8930e320e1bc556b259848c639d9eec1a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/36/b8/c9e68f3a9e33be73f20990b2c065b144ff2d0aa242608a950d8c4f3b56e8/regex-2026.6.28-cp314-cp314-win_arm64.whl", hash = "sha256:c6e6f790d01380a74ad564f216c533b86504afb61bf66f2b2e11e7f1a3e287a7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/03/e6/21c425a37880c650d007c4171c6a80325446d830d85f5fbf335e7205b1e7/regex-2026.6.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3527a72adcbe9e3600f1553b497d397c1a371d227580d41d96c3c5964109b65c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/07/50/6647a7ccf5ffff995ba955a0b7d766440f4e58ce1666549c8ee998f2b972/regex-2026.6.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a644f6408692812f5ead82519eed680e08d5d546fddbd9f7d9514e3c73899aa5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/dc/a3e141a4eaf125e50f63105570c01fa477c06ac5259dcfa95e9b90760e84/regex-2026.6.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8e2fae6bb883648346f84db270dc9aafc29d8e895f62b88a75ccc83b09519820" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/35/ee/2ac1a6b9f167f8ff69f5a789938cc103b60cff41b24a6990daced8b88e34/regex-2026.6.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debe623e09cee97ef9404575e936c610aac9bb08358c5099aaef14644a6871f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/7b/9a5505ee92180bcae300b1018b9ff3d3c19962436e66f2505f255e9fde35/regex-2026.6.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc579c91fb4605773483a8d940b136bcc5b854fff44fa14a1572a038f46563f1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/24/4d/d61a702a9f9d1bd29b22cbef1aed6d477baa961232a7eb4d91b7775b0b3e/regex-2026.6.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7c42be203d84ecf7d487ff23f8a61ef0eb0534fa0fc317a2fce8c065d20618f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d4/60/1308066f5966b65fbb6905b99ba37e9f1cd753dd0ac08485f8257334ee92/regex-2026.6.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8184b4e2fdaf9cdfe77e38f15a4d9dc149168c9c29eb0ea17c5481d3bb80546" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bd/5c/57ce2cb8d714ee0b7f11c7ee4cfe2af66df2b90f147feadcb538609a3a02/regex-2026.6.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:697f103104f5872d64078d8eeac59979960be8ee76115a2d3f31096312e2a400" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/fd/1d5350d3a8a327bff0fccacb911732baf7b5b6f5529c0e3fa602a23e7dad/regex-2026.6.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:714d2b1aa29beef0ddfcdc72ad0771c05326551a8bb0680b0ddf74bfaad87387" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/79/3c9e4f8a0306e030ad5a43bbbc01625fb28d58a813bc52d42fd1cc63fb2e/regex-2026.6.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0f09f62e450cc2f113018cc8412aeea3a120a04e1ca7e801a0d441583f9a3b06" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/12/f747de475b54f4709efb24dd0fbc8467c64cec91f5db0d047b079646ee78/regex-2026.6.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:731ea12d5aeb2577eaef2393d6428b995f76eb35f68a89e03e15a97719d1de19" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/3c/f02f860e0500c1b2d61a79dec7e214b37fb9656281dcddc92397edf96678/regex-2026.6.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:51e952c8783eabd4706d0f63922f219bcfc1bef9b8cb35941c0d1a0396578858" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/6c/28b3fa222513484be9dee26b7222bda109056c43ea28aa2314262ca48816/regex-2026.6.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43248fe4c0ab8fbb223588a0795b11268940072c97bba30ea8f9b49d8cdfde34" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/f0/8f86cf1a1fd85c5ab0c503c9fe4607ad4ad48978b2d8b435d94465e134c7/regex-2026.6.28-cp314-cp314t-win32.whl", hash = "sha256:fc1eddc25ad23c0f1344ab280d961ac595ead48292d7c779497975942373f493" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0f/de/f8613c03b36786ddef2c930d28f9bcae861fcd541cc9203a870956cf1e83/regex-2026.6.28-cp314-cp314t-win_amd64.whl", hash = "sha256:ede8d8e53b6dde0a50f7eca902f0af76d87ab02a55aba7542da68ae3e5dfe83d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/f3/f5ec86839bbabe33b6dee649b62ff9a445d43de6b0ad780cf6b83c56f61e/regex-2026.6.28-cp314-cp314t-win_arm64.whl", hash = "sha256:4da6f6a72f8700b97a1a765e837fb7d5750bfd9f13acea7bae498f573e3a70a8" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca" }, +] + +[[package]] +name = "scantree" +version = "0.0.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathspec" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/e4/40998faefc72ba1ddeb640a44fba92935353525dba110488806da8339c0b/scantree-0.0.4.tar.gz", hash = "sha256:15bd5cb24483b04db2c70653604e8ea3522e98087db7e38ab8482f053984c0ac" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/ce/828467ddfa0d2fe473673026442d2032d552a168e42cfbf25fd0e5264e0c/scantree-0.0.4-py3-none-any.whl", hash = "sha256:7616ab65aa6b7f16fcf8e6fa1d9afaa99a27ab72bba05c61b691853b96763174" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" }, +] + +[[package]] +name = "shortuuid" +version = "1.0.13" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6" }, +] + +[[package]] +name = "storage3" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/13/30/fee43d523d3f680a833a4aae5bf8094de0b9031b0c2bddb3e0bc6e829e1b/storage3-2.31.0.tar.gz", hash = "sha256:d2161e2ea650dc115a1787c30e09b118365589ac772f4dd8643e3a503ecfc667" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2f/b2/60d86a3a99ae743e8a00a6df912f85269b3bc882ba217b8ce1690881c669/storage3-2.31.0-py3-none-any.whl", hash = "sha256:4bf46e8bea320743179a6beafdc7531c5242495e00e0cc22af7c7a9d69d4ed84" }, +] + +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659" }, +] + +[[package]] +name = "supabase" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "httpx" }, + { name = "postgrest" }, + { name = "realtime" }, + { name = "storage3" }, + { name = "supabase-auth" }, + { name = "supabase-functions" }, + { name = "yarl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/8e/54a2f950629689b1613434a61fc3bff5f92f84ba6b20213f5b2add05c1bb/supabase-2.31.0.tar.gz", hash = "sha256:3467b09d00482b9a0138235bdbde7a350426f93cf2a1342372eaddfc669f1206" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/06/5e6f4bf89dedadf81f893832115c1866da1aa093142c9989c2818780dae3/supabase-2.31.0-py3-none-any.whl", hash = "sha256:25f2a99207a75f2d9377e2332783b4389cf56b02cbebdaf0c1743112dcbb704e" }, +] + +[[package]] +name = "supabase-auth" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/8a/408689cf39820f0d46d2731d6747ff94dbefc87ae977b4b5c4066da5b070/supabase_auth-2.31.0.tar.gz", hash = "sha256:0945b33fa96239c76dc8eaf96d7d2c94991950d24b4cfe4a5c2da9aa5e909663" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ca/5e/22f3b0546bb1f0985f06fb1ebf5f6405a3b1bb7a5db01142c26cf148e988/supabase_auth-2.31.0-py3-none-any.whl", hash = "sha256:5e9c8b4ecdee6af04dbcb06455ce78cb15674806fcb6b425170455307d70b0ee" }, +] + +[[package]] +name = "supabase-functions" +version = "2.31.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "strenum" }, + { name = "yarl" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/84/5d/61c2446ed26a57fa5543f9c270a731320911569202340d15341f72cdba7c/supabase_functions-2.31.0.tar.gz", hash = "sha256:4ad027b3ae3bd28b31233339f4db1da6965affd3546f655b421baf40cee2690f" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/90/79/1a8162ce7d705381a4f2668c0da68e097b103c1aa701418a31da52905c7b/supabase_functions-2.31.0-py3-none-any.whl", hash = "sha256:3fdc4c4766152bfda63bdd0e286fc8a06f50e1280711fae4a1dfc9b7e9ebabc6" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55" }, +] + +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f" }, +] + +[[package]] +name = "xxhash" +version = "3.8.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/ed/07e560876a4458987511461187b285071f53cde49dd5b25cd8c51091522b/xxhash-3.8.0.tar.gz", hash = "sha256:d72b2204f37840b0f16f34192c09b994b97bd25823d723d47a1eddfacf06eb43" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/17/2e/4b7c3ab28b7a54ac17eae7e02471c49609d6fc5900856a455feeb847a2a3/xxhash-3.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fc4bd14f873cd0b420f6f1ff5b5cd0dbfeb05b044a11bb9345bcbbf9749636e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/43/e4/09eea3e1bba6a59d64599cb8fba39f2a0872d06e85420eae989a4da61a9d/xxhash-3.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31904979198e913239cb61b49f5b849696aeb3b03340da815d1491ec74dcc602" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/01/59/688bbae31e4e2d6d6eb92acbd3837c0e44ff8c7d435e6da922844ff6efda/xxhash-3.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7338ad13f2b273a1ef0ea97b2db0a059fdb3a1a29298bfa145937c0e4152d341" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2d/de/71484ce0dab2fa4a475705d1ebc37a17ff02d40e5df6767b3255cc53120e/xxhash-3.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54e80e803cb34c8a1d278b491e543af40a588d288589c3e6becc991d5328b46b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/f9/1ac88f02e7df7898541490260b21f2b7f7bd2b233038a0cbd3a3b1bffdc2/xxhash-3.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:353953ea18f5c3fbdd13936fb536aacfb47d5bc06eef0919b1a355df61f7cc31" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/49/7ea1f128d2fe948ed679020f97a0896cdc6c975da5cc69b53a4a9c4a5def/xxhash-3.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d761f983a315630eff18c2fec7360c6b6946f82748026e779336eb8141ef3eba" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a0/da/7d237278dfa1c48722c31010c84a328a317b8885429c8cb6ae4a8fa3e3db/xxhash-3.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3786a9beb9a3b76241cb7db5f5388b460682c12204236389e3221963fc626a6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9b/5f/980fda82620a07d80026b4df371cbca12fca0fd94d7087c4ec5d898da76f/xxhash-3.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c94f5a9a775f36cc522fa2a7e8e2cec512e252d2ac056759f753dc68a79ffc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/71/efa37bc3e91e1c801972bcef99eab877fcbd17ec10aca16c550ee2951107/xxhash-3.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:55ce59f9af37ac861947b43ea3ce7b294b5de77a1234b558d0f07ffad0197624" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/48/19e40320044dc7051e8446505f18557d5661853b87a8770ad399325bb3c8/xxhash-3.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3afa1422a32c7c8e79ad5121dc21eaa5cee9e9e67bffca3f15d15d220d371908" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d3/0d/588499f4d7cd064864ada7adfb9e8785f88a988f1332ed4c1be73d249c15/xxhash-3.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:551fda694938be910529452a89175137c58b4739e41fadff3c047e24b1d74a3b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/54/18/fb2ad593572a33d1b6864b33047b8ca7269273a3c56107b5fd33e0b9c8fb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512eb937c9457e6057e230e005c4709dd2ab63a5989f854d69f31db905750a62" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/63/9e/b880f9ed61b73492e24bb962d76aeb63f18ccb895f0edfb52e20d45ed6f2/xxhash-3.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4931ea93840f750a908efebaf23c71004feacc1a4649ef601b96d400a505c9a9" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3f/89/fc682f93e54e486fc338b26a7d6d0d5cb0ab366269273c2608ac62b51afb/xxhash-3.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2fd4b60e8d9fc3923f39079f185b3425e6d76636fcb66d82a33dd7eba7c30f2f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/71/a4b4122afb2d17ad69e0922cfeddb5ad5c25b02f37eed3dd3819d42e5f55/xxhash-3.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1da00075f1605794298878cb587f7533329693e2a0c45bbd25d6353644add675" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/e5/ed3930f5dc90f4b1bab5ac3be099e8b2e81c1262d85e4adb5f2758e30d23/xxhash-3.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba73801c87d44fa37b2a5feab3004f0a654506027bf032ceb154d94bb74ea772" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/ae/128ea5794387ca54bb4084566db20dbdfc9c21cb17b67d3fcb403927b5ba/xxhash-3.8.0-cp312-cp312-win32.whl", hash = "sha256:0b0836dee6022e22ba516ebfa8f76c6e4bda08d6c166c553e40867bac89e4a54" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/04/a6c182dc566c88e8d1a497d22cc4ffdcfcc0a9fa80325efa6cd4b9002c54/xxhash-3.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3bc2a09b98b8f85c75208cd2b2d2aecf40c77ecb2d72f6bf9757db51a98d3499" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/93/b5/aeda4e79f962c8d58ec60cb20a5abfe91c9f7d62e626f69f6659bc0bd0c4/xxhash-3.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:208e6a8b93426896d803224e9fabe26f8b9c651e8381a80b1fa31812faa091e3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/1f/96f43c5c7c7c4d44721f8d2e5d74698c667a30283c4b10a7e50a56804ee3/xxhash-3.8.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:36434c1d1b0a4729df1fa26ab11bffed1ba52666c0beb605c98a995b470cd143" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1c/d9/7d5d6af4876c6481f2e0acb2dda64dd5209574bf7ba1ad4f6af7a1f8d473/xxhash-3.8.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:a5e6497cefcb2d67f1745c66df9718a99112583af6cc2b70da0312a2eb939f1e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/32/ff/66fed439d78c5a09a1491a85af29bf8923b516530116731a9ac6b14dee2b/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5b00b82f1be708da9404fefd658cf5cf3be5ee3be2aae4bfe3b874255badd342" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/56/b8/9fae0399281095f8aca1f32b21947b3c3c75ad6021b255c5c6e4b11d3866/xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38b0cb0ab7f283413b7cace2bf710d7cf8f702ea82cbc683908691d52028a89b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/61/a4/e53d162c74a8a2950dc063969914387b0680da4c7c20ad17744ec03a3b0a/xxhash-3.8.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:084312171a9798dea85e924b2674f5e1a44933050a1ea1cb1c6b1364e004c66c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/f5/e12397e3f2c4917b6572e103a3277cd27cc56330e304bba61d195d7e5224/xxhash-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6a1a9e845bd3bbc57d9356819e0d198fe23282e0576b398a6282a0f8fdc75aef" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/70/80/c053dc51af5c942229689a0e9cb66fdc999bbd840f645e761f5ab73cbb17/xxhash-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ffbde09743ebaf8957b8426948fbe85eab5e5de0d29eec407fcff5a2812a3cc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f8/a3/294171b67dfe770e1293edcf2a3f7e41302cdb8aefb258585312191b3ffe/xxhash-3.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a6dee3952c2b6e82e7f1dbc5dbc6167f9c84126851def7926e32827c2816169c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/80/c3/d141bfdeca785c8c680abf867d4b52a5e64a55d90df242c3141a3e58c4b2/xxhash-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf8ff8e12416c9fa05b43c7509b9332d6ffc4090413c4e7a1dee8599763b6d59" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/09/5a/aeaf35143a6f3d44db73298e861405bdd9c9dacaedfc369cb43d9fd65282/xxhash-3.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cebbb322df4d97d8ef2704f49ed2f6f21f6702fafa0dc0c2a6ae70e904205689" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/3e/f8ca782bb34f99693faab70a7989bcc84f62ffe93c9a4cca464a33507a4b/xxhash-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a8d08707b4100ebce598fc59fadf04b42d79b855818d6994f8f0fffd1df8edb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d4/fa/ddbee4ff1542c2e88e72269a5a6bd18c3f26a80c2514e0918f5d1f3e9ec5/xxhash-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cf5427602dda15d8ce3c6d870d29bf07d43975f59c9d6d3f7f6f93a901b28b12" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/25/f5/a680d48dddab37ab2fd9189ca03f775e29e3627122e30790816d7eb365af/xxhash-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97d7bd715ea5050b6c9638b52c62adf3055b648ef6eee6892a4cd9697b530191" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/22/b1/7ac129b74981c07f1ff9c649f204465e86f83f9f29b2ebdc70d91514c365/xxhash-3.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cd25bbbab37d898f6e5a90905ce6ae2c1f8bd6668c07cef406fb3e8c8c570dd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/67/e6/43e673411249dd63f6cd974523a1b32fad75cf5453e363bc8f44af215fb9/xxhash-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3e30e5c057f483c3c53a11b53eba091a737cb19dfead36c8b23bf5beb4a169cd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/95/87f8baf41f63130f3637104b7a610f82b20106332fc6e289c8dbf7955d0e/xxhash-3.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:07dd44d992ebd456752bc25b1c42cd172d94bd8cb24049300449ad0716081c3a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/c9/3369b497cd1f926b930c52fd2400606f177790d887b49f9e86bddcc24562/xxhash-3.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3118600a3102d4707dc1c485dbc3acbbbf37819069ad3e7854e77b923745d76b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/34/c8/03dceb86a8128858ac105bd6e282d62b3db6fd421a79bd8a9f6b8cdc47a7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ed37b0c95d8fb3fbaad5e13cc0a9727eb8739d1d54b2adef28108c250cada3a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/47/a5/ebd43eeb1af1dd8f0201943688b20958e99d3f6eb36481fb8c37b55ef139/xxhash-3.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bb043da412e478e7b1db3407051124b85b133803794d3809ad6d92870b304fc7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/24/c873e41a3c00dacc385c8ff08c007723f6a528922c1cea7fd9684e86dae7/xxhash-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:196fc132683d9311a0bdce8388ee52bfa07fdc1987cc428a27956e47ccd7b50d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/1b/c671272fe28f70574e3c574d58465f26460154bcc68876121872afa1c14d/xxhash-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfb5411af3b77c75e99db100aa15c5ba623c85d72c565e4d7a0ed1a986ff766e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/57/43/b45a52f795812cb769b6ac159e69b605d18b1c067749e63dcac159e90064/xxhash-3.8.0-cp313-cp313-win32.whl", hash = "sha256:6d1d6179e26830c6690fac63f76d372f69714b977e12ca9c42188a60f51c59f5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/42/2bd70e4eec25dc5990652979d708d4d7c999793d7d5af5d0e48ab4374dc1/xxhash-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c92427a56a12f4d5c7bb26dbb9e9a4658c313ecb6c2f1dca349902e3822df07" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d5/c8/2fe61edb6144183cf094035a8c5354c65a073127acf6379655ed1e705b70/xxhash-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:9fc8453642c1c6d38b4fbac8901c2452ce1fa88b27f003bfee6703cbfae9bd63" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/b8/81d17a993b9a4750ba426ce966421681bb4b8e82a460cd346756491b8cc2/xxhash-3.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:efcacb644a915f010dc477447b045e5dcde1afaa40d16b2f0f8e7cd99c9e1635" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/3b/f5a368e3273440b3ea58fbd3f0b08c19f552b25ca59f43f5732ca96d2126/xxhash-3.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d1e0dbc510cff94c5efbcc2b82c28b41519fad09b5b1f9f3d99c63e3940e49a0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a6/ab/f424359c91c55f564fbbe4e454a126eb522471109f67376f20ad19c5e663/xxhash-3.8.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ff19d016a41c90d1f519005887191896b6da1274e1d5d48b347e17eb798ffc5a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ac/c2/434579ef9235123b6c9bfa89c5614e0001e988613b91557b24aa326d9faa/xxhash-3.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aafc3eab99c50508852e34307e9565933bf128cad084cac7d2471b7ab1743de0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c0/6c/3c0c917331ca3c71f826cedce2127f230624e2b49b992472dd5e9e72101c/xxhash-3.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e521368ed79ae6c4d31e1e417726643c49d7d6e286f4fdabf9a8330ed8a8ff7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/f3/a8bb98d3307c67e88be9642dff52854c3de3f488f95989b60ff69c8dcc42/xxhash-3.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a0127688d116ec0c225e7e1f744e3f206de2b8822ffeb31a9ab5cc6384f92c5" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f7/73/fab69a2e5b6353dde643209fe9b6adf4fbd64c888e531deffc476bfb2635/xxhash-3.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:22c0b17da2f9fea0f8836538512249871b359141616bad44c58d238b5f011f40" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/5b/ba34099b5278097ec9c68c0b740719813553bfd11ca17e7353de6d2a41e3/xxhash-3.8.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d49465646b1a5e3b1729c5f636e05676a2fb52e203e3b22a5411c416c4c5302" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/0c/90aba4708a37fe752b324a7cbf10058eaa33e892cdd62751ff17a5137b93/xxhash-3.8.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2853dea1e30ed00ca87dd87d76da5da063d302b823b3fb80ccd18421de0f251" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/38/46/42e349e2d3017b2688f4cb301742c37c438e77963e3fef711edce2fc5c65/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:82f0102a2a3760287b7cd7f9e0a30edd4c3b18762ed1a242208d43c8e2bcf30b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ee/15/741b947ae3c768e82018c46846f8616f6aa9b5042649f318a1a6897defe3/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b8414a66a7524596d841cad5dc1adab6ce76848db5ab2b83db911fbdab1417af" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c6/b4/a9db84c9458fc8f53eaf0051377d1e9eecd9f330fb1225640027417a309d/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dbaa73df10414ea1e41b98691a9d8241d4c47ad8d02c726587a3cda05278e53" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/20/92/60a868cd34851746d0b0d95dced0f42867c7c00606f6e5dba85b70b232ce/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:43fc9aaba10ab4267c90793601f60d35c3c9caa1544eceb483618a71ad9ce7da" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/6a/168ca46a4679c32aae9246caa1fddf35981d6304487e45e992b3d4530324/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ec5eb3d28fbb9802c6d2526f772133a06c91d6f03756fcc67c834b642ffdd51d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/18/0b/13646b348c07679c818791ab2d35415db5cb20f3bc77daaa255909a401b4/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:2b77c301b644cd9b4d0749a3291081ec2048a6bef7fe0487c993bbba3efb9ce0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/59/9a/3d244b2acf6bbd86a363817ee09084b4684e8e11840663e19869e9e0d952/xxhash-3.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d7ece11a132325353890a144c30119073617a1299c593ca29b96c315b07e1edd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/c7/143410d026a6e0d86dc69037ec2a3b8db810a54e7f443b340ac17612be2e/xxhash-3.8.0-cp313-cp313t-win32.whl", hash = "sha256:b21db84df7b9d54d9e4195a964243c1b32d745c6fbc0cfcfffee1d4bd297196a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6c/db/2240b0638161637b2f310231748a7a6a06c79fb43a3adb34c96f359762bf/xxhash-3.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0643b7d9f598f6da6f1f6b899f4358250d0fb853242e2d712cbde27bf5a99d29" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e0/d8/52038e4fa5baf4f00654a225516168d02908edfec7ca104fbefc58af394f/xxhash-3.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4bbacf2e938526969f8ab3334d4ac3da14ea059e1dfd1339a92f9091467e750f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/ef/a09907aa28bdcdf6810d5c26656b154c60c0f06bb8db8442a1192d9c227a/xxhash-3.8.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:557e2a7cc0b6a634cf9c8e5c975d96b7da796fdeb1824569d760cf0f25b6f33f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d2/4d/d991ff77bc489c2231025e64e570502156d573c7bff69c917589cc307089/xxhash-3.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:dad744d1613cbfddb844dad93adbffbd51c3e9f53ceea9568f7c3b94bedc19a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/cc/0e/553eab001f1e274da73da074968cdc8be8cacfb318937ab9871b8e1909cb/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:953f29b22c04b123cf3cd2e08bccde3a73184aeda5a1038e0054cb3355644120" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/55/d5/d0f4dbe7b4d9ce0125f16e45ec0be5e04f6a172edb4e2fa551c4f2eb5d7a/xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:aa699e0253ceffecf41cae858d0a11f2439d6874a0890b556387bffe11dc1c08" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2e/2f/b332c7bede6a676343f2c9c8dea233c8c82753eaeda6f7a2c321d8c58ca3/xxhash-3.8.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e232c82466babc13e956d53aa84d0149660ed6886bc195248bb4d03bf2eca301" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b3/5b/2bf3c9e61c7cf8f53bce937af45e22b72bb1f224d5afb20352beba0d628d/xxhash-3.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f75fd1c6a5028f345cd4a8c52f4774d2e5b7809fa58111c60a5502b528914a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/64/b6/e88521f5736c181b89bfb7ab756f0ca658a8a1ecece7277b75e167717614/xxhash-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b49d7e09b211a1ad658dbe2dbf6561eb92f2e6926bd1101e2d023178371f2d6f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ed/a2/fba440739fa5f86d2c28738c202e88d3dd063290c8bbb20e183c5334456a/xxhash-3.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ceb702bc8e56b7f1f1413d42aa294045b9a0e4c9888e07edc5cd153e8c4c948f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/2c/1c/4a1639efec16416695d6c7bc6b224d3f607e0b8cbe2409fa81081a849d1c/xxhash-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f3c96e06bdb122e8cc84f5c7088579f3102b828efd62e9dc964a9d17c7b89e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/d1/8ce471f8d6752384f972fd5f6363f2e8d8b867a89fbd724c6dbd91d2bb98/xxhash-3.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:415a8d06ac9bea36b1e06b603a347e0f62401042a97d7bfccec8ae2da12ad784" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/77/400a281683fd39c54e2ac497fa67bdf886baaadb8c0ba58f7e1ea1d7692e/xxhash-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f5ccdd2deb5dce31201cc0eec94388cce97e681429073db50903fab0a0a8a0d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/aa/a6/edda651cfa0ba8e921791e93468fae655b63894d89730fcbfe46704f0d0a/xxhash-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a6cf81bc699d3a5ebfcf2fdb2a7bd2e096708d7de193f6f322944a02ba00953" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/dd/da/50f764ec6a93d3961fce294567e41bfca0e66d168deed354a3dc90ebeba6/xxhash-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4d12a04d7ffc0359f0eadc4535a53cab113044c8d2f262c7e9a56950a5ed50e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bb/49/9fe4ed5aac6f38629cc83b34f84748b83ad8295a578ec6a49d8bf896cafb/xxhash-3.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d209373fcb66138c652cf843385ee60866e50158a7869bbbf8b322d9a822b765" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/83/f5/1147e03c0553ed22bbae9ce47503c37ee0c5f95592aae10f339c25f61de9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b88a3fe28277811e599efa6e1c96abce8a77d60dd79c94da7a9b5c377c172b7b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b8/d8/92daf66c1966c84da5c97a06ced1480208d3a3bd465cb0630565ec00d1b9/xxhash-3.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5d5a888a5ef997cb35f1aad346eb861cd87ecfe24f5e25d5aa4c9fd1bd3950c2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bd/c0/080c1a92972667e183c04b03f33c877f8ec61cfa3570e61731077286648d/xxhash-3.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:de2836e0329c01555957a603dcd113c337c577081153d691c12a51c5be3282b0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d9/d5/cbc4e5b2bee10c94cba05b5bb2b8033e7ef44ae742583fdafcd9188e33ed/xxhash-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4bc74eedb0dd5827b3be748bacf9fdb50004037a3e16c7ddb5defae2682cef71" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/76/f7/09679b00e192b741b65c230440c4f7e6df3251a9ad427a518ddf262ec71a/xxhash-3.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c571b03d59e339b010dc84f15a6f1cff80212f3a3116c2a71e2303c95065b1f6" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5f/1b/f43ec36e8c6a20c77be0bcca23f0b133ed8a0312681500d1676eebd71924/xxhash-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:87626acdd6e2d762c588a4ffe94258c5ef34fb6049a4a3b25019bdb7f9267a9b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/45/2e/a3e3a779c5e4789daf975e05cc1c7f11bae724a03855120029d4592c8e63/xxhash-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:076d8a4fb290af952826922aa42a46bfc64caa31662ce4e2925a445d0e6ce57f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/da/1c1e078ac290afff304a541a2a60965beb369ad65b4f30ec93ea1e0b7210/xxhash-3.8.0-cp314-cp314-win32.whl", hash = "sha256:52f8c7c9833d947e60df830671f6eca810d7c667051243985a561c79f1a3d545" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/7a/d455cb83d5e3c94046234294fb5dbbe5da600d1bbdf76b9527756920cce9/xxhash-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:4fbfcb7dd307e23189a71050f6e27746926590330f37d5fd2ffcb8ea78de1f42" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/89/8f/1b14471f617bc96edbb9566099a162d918a981381c398114726cc600b76c/xxhash-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:ecef1e65b4715c7326002073763fe94cc44c756a0698508abb915ab3d6be6e3d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/11/8d/51ad2f9f784121c8057ef1ba36362f58d4595cbcad16322941f5b73eb53d/xxhash-3.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:02ed856a765cb6e006168595d9455ac8c3c4d60cc04cd47a158a1ac677d68f0f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/14/175c573ae4fac48bf21a82e5b9ceec75d64c520c51ca08de3105de539438/xxhash-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eec30461a7b457611098ba7ab09363e36c8b2645b4687fb6f3d405bb646e3410" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/96/08/f83efabd350a50c31c851b88891e318a6f07bdbf40a43d0f7bb6cedade7f/xxhash-3.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b471744912d1ce5dd6d3975b7525e77518359ebf3aa1bd7d501e199f5ae488ea" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/78/2b6d12da9cf572c84d93b88ecbf9bf6539a7c5219bde128b214396b97c8b/xxhash-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3748d71202bf3f279e77cb8b273b6d0f29d1bcaefb6ce6cb03b95f358863ba37" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d0/0a/755eeb1882634983b24e6375a95ed233228dc48f0ef12655388bf3c7eeaf/xxhash-3.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b3bf59ea94b2a23b0f992769804ab9401d5cdcd9df0062fe2cd78a491ae8851" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/77/f2/09b1231cad17c314e51664c4a004c919108ec59aba10f9a28fa061e7b8be/xxhash-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f061aa5379eba249e9367b179515571e632be6d1b6f55ac139e6fe3d08463c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b2/24/de756d55547953494eb6775aea92e258035647b3ecb8547618cd549001e1/xxhash-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:680d70896a61fc920cc717a0a8fe8a9fb5858c563184666e31874caa54a16d9e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e5/63/b8147633e32f98ef2b4bb0dfca82f0f63e2b02ff179f20664af64c4216a7/xxhash-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14973fbdee136588e57447401b521f466a42faca41eecdf35123c73103512ca8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/37/ba051d8f0380d3cf845b23ba058a17d32025846463eb6bf885887fc8effe/xxhash-3.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96c6bca2486cdc58b125966817a92a6abe6ef1fab86b2f8798a7e93488782540" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/6f/36e0a27dd27ffa3f7b521650cbcd52a00fb86b71343ffadb642374e8263c/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b1109ae238e932d8482f9cb568b56a405cc73bc7a36b837844087f1298dd218" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/73/2663dbf4c09386a9dcc8a94d7a14b4609ed4bad8180ced5b848e60a9b660/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1da5db0863400eade7c5a31969754d1392189f26b4105f6631da2c6c7ea3bccc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d6/58/f3ce1bc3bb3971191f6521273ddae98d3c610bcefbbed5327c3b3627c12f/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c61b5a0f21ace5e886f177cce43826d85a7c84e35a9e17cb6d1b4ac0b7a7d833" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4d/51/835706a36cdc00e5b638fba9b22218b3d40d23a7677c923feca8a3f55b98/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1db4f27835a450c7e729bc9330c6e702113711cea1f873d646e3a31fe96a9732" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/47/b0b62caa3caee58ab9de8969f66aef1c3729886f3ff60e173fda3f2762be/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4788a470f946df34383abc6cd345088c13f897a5ee580c4cdd12b1d32ad218ef" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/69/c4/60e6d18a0e131c7af622374af9deede15d3c47d8e5e7221933481b57b319/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3b6dfa83096cb1e54d082acebaf67f0c42667c56dc48ba536a76cac08d46391e" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/12/9f/c9627daa052be39a932d0e17c6bf6a9041d2cde3afacbded9196acf70261/xxhash-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:57ec0ba5299a9a7df376063c139f5826ff0c89b438703939af3d252c31ca96a4" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/38/92916e008a84c1f1a9aef82e4363cdc478a722ff69e59c6afbf93d3d1fda/xxhash-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:d9a61f23b999baeb84102aba767b1b3e94958eab94e6c11b08927e7dc4200795" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/31/7c/e413bc75121d9628bf023b2ed251411ca3a447cf00cd9aa3438ab17f6c67/xxhash-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:61069b260fff84116235bb93845f319284dc6b42527c215af59264f4c2ee3468" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f6/eb/21a96e218375bd8b6ecd6d07cf60c8ff1a046e93cdedc3cf7bc3309edf7b/xxhash-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:73cecd431b4f572d38fcf1a7fe85b30eb987778ef9e7a70bc9ffcf2d64810e6f" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d" }, + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/simple" } +sdist = { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602" } +wheels = [ + { url = "https://global.block-artifacts.com/artifactory/api/pypi/block-pypi/packages/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f" }, +] diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 9399766f17..b06891dd84 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -9,7 +9,7 @@ Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent (send_message, etc.) ``` -Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/zed-industries/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)). +Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)). ## Prerequisites @@ -57,16 +57,14 @@ That's it. The harness spawns `goose acp`, connects to the relay, discovers chan ## Running with Codex -[codex-acp](https://github.com/zed-industries/codex-acp) wraps OpenAI Codex in an ACP interface. +[codex-acp](https://github.com/agentclientprotocol/codex-acp) wraps OpenAI Codex in an ACP interface. ```bash -# Build the adapter (requires Rust 1.91+) -cd /path/to/codex-acp && cargo build --release +# Install the adapter (npm package — no Rust build required) +npm install -g @agentclientprotocol/codex-acp # Run export OPENAI_API_KEY="sk-..." # required — use an OpenAI API key, not a ChatGPT subscription -export BUZZ_ACP_AGENT_COMMAND="/path/to/codex-acp/target/release/codex-acp" -export BUZZ_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"' buzz-acp ``` diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index ea11fce12f..ebfd8fb4a8 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -92,6 +92,9 @@ pub enum AcpError { #[error("Hard turn timeout exceeded")] HardTimeout, + #[error("Agent did not stop within {0:?} after cancellation")] + CancelDrainTimeout(std::time::Duration), + #[error("Request timeout — agent did not respond within {0:?}")] Timeout(std::time::Duration), @@ -101,8 +104,21 @@ pub enum AcpError { #[error("Protocol error: {0}")] Protocol(String), - #[error("Agent reported error: {0}")] - AgentError(String), + #[error("Agent reported error (code {code}): {message}")] + AgentError { code: i64, message: String }, +} + +/// Build an [`AcpError::AgentError`] from a JSON-RPC error object, +/// preserving the numeric code. When the `message` field is missing or +/// non-string, fall back to the full JSON object so provider-specific +/// detail (e.g. a `data` field) is not lost. +fn agent_error_from_json(error: &serde_json::Value) -> AcpError { + let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-32000); + let message = match error.get("message").and_then(|m| m.as_str()) { + Some(m) => m.to_string(), + None => error.to_string(), + }; + AcpError::AgentError { code, message } } /// ACP client that owns an agent subprocess and communicates over its stdio. @@ -175,6 +191,148 @@ pub struct AcpClient { goose_usage: UsageTracker, } +/// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape +/// collisions. When both sides have an object for the same key, the merge recurses so +/// unrelated nested keys from `base` are preserved. +fn deep_merge( + base: &mut serde_json::Map, + overlay: serde_json::Map, +) { + for (k, overlay_val) in overlay { + match base.get_mut(&k) { + Some(serde_json::Value::Object(base_obj)) + if matches!(overlay_val, serde_json::Value::Object(_)) => + { + // Both sides are objects — recurse to preserve unrelated nested keys. + if let serde_json::Value::Object(overlay_obj) = overlay_val { + deep_merge(base_obj, overlay_obj); + } + } + _ => { + // Scalar, array, type mismatch, or new key — overlay wins. + base.insert(k, overlay_val); + } + } + } +} + +/// Build the merged `CODEX_CONFIG` environment-variable value for a Codex agent spawn. +/// +/// Returns `Some(json_string)` when `has_generated_codex_config` is true (Buzz injected a +/// `CODEX_CONFIG` entry via `codex_network_env()`), `None` otherwise. +/// +/// # Merge contract (when `has_generated_codex_config` is true) +/// +/// 1. **Persona base** — the first `CODEX_CONFIG` value in `extra_env` is taken as +/// the base object (all keys preserved, recursively). When there is no persona entry, +/// the generated entry serves as the base. +/// 2. **Generated overlay** — all subsequent `CODEX_CONFIG` entries are deep-merged into +/// the base so unrelated nested persona keys survive. +/// 3. **Parent-env precedence** — if `parent_codex_config` is `Some`, its keys are +/// deep-merged into the result (parent wins on colliding keys at every nesting level; +/// unrelated keys from either side survive). +/// 4. **Forced overlay** — `sandbox_workspace_write.network_access = true` is applied +/// last so relay access is guaranteed regardless of operator / persona config. +/// +/// When `has_generated_codex_config` is false, the function returns `None` and the +/// caller handles any persona-supplied `CODEX_CONFIG` with ordinary operator-wins +/// semantics (no merging, no sandbox widening). +/// +/// # Errors +/// +/// Returns `Err(AcpError::Protocol)` when `has_generated_codex_config` is true and any +/// `CODEX_CONFIG` value is not valid JSON or is not a JSON object, or when +/// `sandbox_workspace_write` is present but not an object after all merges. +pub(crate) fn build_codex_config_env( + extra_env: &[(String, String)], + parent_codex_config: Option<&str>, + has_generated_codex_config: bool, +) -> Result, AcpError> { + // Without an explicit Buzz-generated overlay signal, skip the merge entirely. + // Any persona CODEX_CONFIG is handled by the caller with operator-wins semantics. + if !has_generated_codex_config { + return Ok(None); + } + + // Collect all CODEX_CONFIG entries from extra_env in order. + let codex_entries: Vec<&str> = extra_env + .iter() + .filter(|(k, _)| k == "CODEX_CONFIG") + .map(|(_, v)| v.as_str()) + .collect(); + + if codex_entries.is_empty() { + // has_generated_codex_config is true but no entry in extra_env — shouldn't + // happen in practice, but treat as no-op rather than panic. + return Ok(None); + } + + // Parse all entries; first one is the persona base (or the generated entry if no + // persona CODEX_CONFIG was set), rest are additional generated entries. + let mut parsed_entries: Vec> = Vec::new(); + for (i, raw) in codex_entries.iter().enumerate() { + match serde_json::from_str::(raw) { + Ok(serde_json::Value::Object(obj)) => parsed_entries.push(obj), + Ok(_) => { + let source = if i == 0 { "persona" } else { "generated" }; + return Err(AcpError::Protocol(format!( + "CODEX_CONFIG {source} value is valid JSON but not an object" + ))); + } + Err(e) => { + let source = if i == 0 { "persona" } else { "generated" }; + return Err(AcpError::Protocol(format!( + "CODEX_CONFIG {source} value is not valid JSON: {e}" + ))); + } + } + } + + // Start from first entry, deep-merge remaining entries. + let mut base = parsed_entries.remove(0); + for overlay in parsed_entries { + deep_merge(&mut base, overlay); + } + + // Deep-merge parent env (parent wins on colliding keys at every nesting level). + if let Some(parent_raw) = parent_codex_config { + match serde_json::from_str::(parent_raw) { + Ok(serde_json::Value::Object(parent_obj)) => { + deep_merge(&mut base, parent_obj); + } + Ok(_) => { + return Err(AcpError::Protocol( + "CODEX_CONFIG in parent environment is valid JSON but not an object".into(), + )); + } + Err(e) => { + return Err(AcpError::Protocol(format!( + "CODEX_CONFIG in parent environment is not valid JSON: {e}" + ))); + } + } + } + + // Force sandbox_workspace_write.network_access = true (our invariant, always wins). + let sws_entry = base + .entry("sandbox_workspace_write") + .or_insert_with(|| serde_json::json!({})); + match sws_entry { + serde_json::Value::Object(sws_obj) => { + sws_obj.insert("network_access".to_string(), serde_json::Value::Bool(true)); + } + other => { + return Err(AcpError::Protocol(format!( + "CODEX_CONFIG sandbox_workspace_write is not an object (got {}); \ + cannot set network_access=true", + other + ))); + } + } + + Ok(Some(serde_json::Value::Object(base).to_string())) +} + impl AcpClient { /// Kill the agent subprocess and wait for it to exit (no zombies). /// @@ -207,11 +365,17 @@ impl AcpClient { /// Spawn the agent binary as a subprocess and connect to its stdio pipes. /// + /// `has_generated_codex_config` must be true when `codex_network_env()` successfully + /// injected a `CODEX_CONFIG` entry into `extra_env`. The spawn path uses it to + /// trigger the recursive merge + forced `network_access=true` in + /// `build_codex_config_env`. Pass `false` for test spawns and non-Codex agents. + /// /// After spawning, call [`initialize`](Self::initialize) before any other method. pub async fn spawn( command: &str, args: &[String], extra_env: &[(String, String)], + has_generated_codex_config: bool, ) -> Result { use std::process::Stdio; @@ -226,12 +390,41 @@ impl AcpClient { .kill_on_drop(true); // Per-persona env vars (e.g., GOOSE_PROVIDER, BUZZ_AGENT_PROVIDER). - // Only injected if not already set in parent env (operator precedence). + // For most keys, operator precedence wins: skip injection if already set + // in the parent environment. + // + // CODEX_CONFIG is handled specially via build_codex_config_env: + // • has_generated_codex_config=true: merge all CODEX_CONFIG entries + parent + // recursively and force network_access=true. + // • has_generated_codex_config=false: return None; any persona-supplied + // CODEX_CONFIG falls through to the normal operator-wins loop below. + let has_codex_config = extra_env.iter().any(|(k, _)| k == "CODEX_CONFIG"); + let parent_codex_config = if has_generated_codex_config && has_codex_config { + std::env::var("CODEX_CONFIG").ok() + } else { + None + }; + let codex_config_value = build_codex_config_env( + extra_env, + parent_codex_config.as_deref(), + has_generated_codex_config, + )?; + // When the merge path was not taken (None returned), any persona CODEX_CONFIG + // entry falls through to the standard operator-wins treatment below. + let codex_merge_active = codex_config_value.is_some(); + for (key, value) in extra_env { + if key == "CODEX_CONFIG" && codex_merge_active { + // Handled by build_codex_config_env; skip here to avoid double-setting. + continue; + } if std::env::var(key).is_err() { cmd.env(key, value); } } + if let Some(merged) = codex_config_value { + cmd.env("CODEX_CONFIG", merged); + } // Spawn the agent in its own process group so SIGKILL doesn't propagate // to the harness's own process group on Unix. @@ -625,6 +818,12 @@ impl AcpClient { /// cancellation look broken. This variant gives the agent a short chance to /// acknowledge cancellation, then returns a timeout so the caller can respawn /// the agent process and actually stop the work. + /// + /// The `grace` window is a cleanup deadline, not the turn's real max-turn + /// wall clock — a bounded drain that expires maps to + /// [`AcpError::CancelDrainTimeout`], never [`AcpError::HardTimeout`], so + /// callers can distinguish "agent didn't stop in time" from a genuine + /// configured hard-cap breach. pub async fn cancel_with_cleanup_grace( &mut self, session_id: &str, @@ -632,8 +831,13 @@ impl AcpClient { ) -> Result { let _ = self.current_hard_deadline.take(); let hard_deadline = tokio::time::Instant::now() + grace; - self.cancel_with_cleanup_until(session_id, hard_deadline) + match self + .cancel_with_cleanup_until(session_id, hard_deadline) .await + { + Err(AcpError::HardTimeout) => Err(AcpError::CancelDrainTimeout(grace)), + other => other, + } } async fn cancel_with_cleanup_until( @@ -863,7 +1067,7 @@ impl AcpClient { if let Some(id) = msg.get("id") { if *id == serde_json::json!(expected_id) && msg.get("method").is_none() { if let Some(error) = msg.get("error") { - return Err(AcpError::AgentError(error.to_string())); + return Err(agent_error_from_json(error)); } return Ok(msg["result"].clone()); } @@ -1185,7 +1389,7 @@ impl AcpClient { let _ = ack_tx .send(crate::pool::SteerAck::PromptCompletedNeutral); } - return Err(AcpError::AgentError(error.to_string())); + return Err(agent_error_from_json(error)); } if let Some((_, ack_tx)) = pending_steer.take() { let _ = @@ -2312,7 +2516,7 @@ mod tests { } async fn spawn_script(script: &str) -> AcpClient { - AcpClient::spawn("bash", &["-c".into(), script.into()], &[]) + AcpClient::spawn("bash", &["-c".into(), script.into()], &[], false) .await .expect("failed to spawn test script") } @@ -2354,6 +2558,27 @@ mod tests { ); } + /// `cancel_with_cleanup_grace`'s bounded drain deadline must map to + /// [`AcpError::CancelDrainTimeout`], never [`AcpError::HardTimeout`] — + /// the two share an underlying deadline mechanism but must not share + /// classification, since callers dead-letter a real `HardTimeout` and + /// must not dead-letter a drain that simply ran past its grace window. + #[tokio::test] + async fn cancel_with_cleanup_grace_maps_expiry_to_cancel_drain_timeout() { + // Agent ignores `session/cancel` on stdin and keeps producing noise + // forever — never drains within the grace window. + let mut client = spawn_script("while true; do echo 'noise'; sleep 0.01; done").await; + client.last_prompt_id = Some(999); + let grace = std::time::Duration::from_millis(200); + let result = client + .cancel_with_cleanup_grace("test-session", grace) + .await; + assert!( + matches!(result, Err(AcpError::CancelDrainTimeout(g)) if g == grace), + "expected CancelDrainTimeout({grace:?}), got {result:?}" + ); + } + #[tokio::test] async fn idle_resets_on_stdout_activity() { // Send valid JSON (session/update notifications) to reset the idle timer. @@ -2675,7 +2900,7 @@ mod tests { /// which is fine — these tests don't read from the agent, they just /// feed JSON into the parser. async fn spawn_inert_client() -> AcpClient { - AcpClient::spawn("cat", &[], &[]) + AcpClient::spawn("cat", &[], &[], false) .await .expect("spawn cat as inert client") } @@ -3007,4 +3232,265 @@ mod tests { client.handle_goose_usage_update(&bad2); assert!(client.take_turn_usage().is_none()); } + + #[test] + fn agent_error_from_json_falls_back_to_full_json_when_message_missing() { + // Errors without a string `message` field (e.g. only a `data` field) must + // not be silently truncated to "unknown error" — the full JSON is preserved. + let error = serde_json::json!({"code": -32000, "data": "quota exceeded"}); + match super::agent_error_from_json(&error) { + AcpError::AgentError { code, message } => { + assert_eq!(code, -32000); + assert!( + message.contains("quota exceeded"), + "expected full JSON in message, got: {message}" + ); + } + other => panic!("expected AgentError, got {other:?}"), + } + } + + #[test] + fn agent_error_from_json_uses_message_field_when_present() { + let error = serde_json::json!({"code": -32001, "message": "auth denied"}); + match super::agent_error_from_json(&error) { + AcpError::AgentError { code, message } => { + assert_eq!(code, -32001); + assert_eq!(message, "auth denied"); + } + other => panic!("expected AgentError, got {other:?}"), + } + } + + // ── build_codex_config_env ──────────────────────────────────────────────── + + fn env(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + const GENERATED: &str = r#"{"sandbox_workspace_write":{"network_access":true}}"#; + + #[test] + fn build_codex_config_env_returns_none_when_no_codex_config_in_extra_env() { + // Non-Codex agents: extra_env has no CODEX_CONFIG → None regardless of signal. + let extra = env(&[("GOOSE_PROVIDER", "openai")]); + let result = build_codex_config_env(&extra, None, false).unwrap(); + assert_eq!( + result, None, + "no CODEX_CONFIG in extra_env must return None" + ); + } + + #[test] + fn build_codex_config_env_generated_only_single_entry_with_signal_true_merges_with_parent() { + // No persona: Buzz injects one CODEX_CONFIG; signal=true. + // Parent may have its own CODEX_CONFIG — deep_merge applies, network_access forced. + let extra = env(&[("CODEX_CONFIG", GENERATED)]); + let parent = + r#"{"some_operator_key":"val","sandbox_workspace_write":{"operator_key":"keep"}}"#; + let merged = build_codex_config_env(&extra, Some(parent), true) + .unwrap() + .unwrap(); + let v: serde_json::Value = serde_json::from_str(&merged).unwrap(); + // network_access forced true even though only one entry in extra_env. + assert_eq!( + v["sandbox_workspace_write"]["network_access"], true, + "network_access must be forced true with signal=true" + ); + // Operator key preserved via deep_merge. + assert_eq!( + v["sandbox_workspace_write"]["operator_key"], "keep", + "operator nested key must survive" + ); + assert_eq!( + v["some_operator_key"], "val", + "operator top-level key must survive" + ); + } + + #[test] + fn build_codex_config_env_persona_only_signal_false_returns_none() { + // Persona set CODEX_CONFIG; Buzz did not inject a generated overlay (signal=false). + // Must return None — no merging, no sandbox widening. + let persona = r#"{"some_feature":"on"}"#; + let extra = env(&[("CODEX_CONFIG", persona)]); + let result = build_codex_config_env(&extra, None, false).unwrap(); + assert_eq!( + result, None, + "persona-only CODEX_CONFIG with signal=false must return None" + ); + } + + #[test] + fn build_codex_config_env_returns_none_for_persona_only_no_generated_overlay() { + // Alias: same scenario as above, confirms the old count-based path no longer exists. + let persona = r#"{"some_feature":"on"}"#; + let extra = env(&[("CODEX_CONFIG", persona)]); + let result = build_codex_config_env(&extra, None, false).unwrap(); + assert_eq!( + result, None, + "persona-only CODEX_CONFIG with signal=false must return None" + ); + } + + #[test] + fn build_codex_config_env_sets_network_access_from_scratch() { + // Persona + generated overlay, signal=true: network_access is forced true. + let persona = r#"{}"#; + let extra = env(&[("CODEX_CONFIG", persona), ("CODEX_CONFIG", GENERATED)]); + let merged = build_codex_config_env(&extra, None, true).unwrap().unwrap(); + let v: serde_json::Value = serde_json::from_str(&merged).unwrap(); + assert_eq!(v["sandbox_workspace_write"]["network_access"], true); + } + + #[test] + fn build_codex_config_env_persona_keys_survive_merge() { + // Persona has CODEX_CONFIG with unrelated keys; generated overlay must + // force network_access=true without erasing persona keys. + let persona_cfg = r#"{"some_feature":{"enabled":true}}"#; + // Config::from_args appends generated AFTER persona env vars. + let extra = env(&[("CODEX_CONFIG", persona_cfg), ("CODEX_CONFIG", GENERATED)]); + let merged = build_codex_config_env(&extra, None, true).unwrap().unwrap(); + let v: serde_json::Value = serde_json::from_str(&merged).unwrap(); + assert_eq!( + v["some_feature"]["enabled"], true, + "persona key must survive merge" + ); + assert_eq!( + v["sandbox_workspace_write"]["network_access"], true, + "network_access must be forced true" + ); + } + + #[test] + fn build_codex_config_env_nested_persona_keys_survive_when_parent_has_same_top_level_key() { + // Persona has sandbox_workspace_write.persona_only; parent has + // sandbox_workspace_write.parent_only. A flat top-level spread would drop + // persona_only. deep_merge must preserve both nested keys, and + // network_access must be forced true last. + let persona_cfg = r#"{"sandbox_workspace_write":{"persona_only":"keep_me"}}"#; + let extra = env(&[("CODEX_CONFIG", persona_cfg), ("CODEX_CONFIG", GENERATED)]); + let parent = r#"{"sandbox_workspace_write":{"parent_only":"also_here"}}"#; + let merged = build_codex_config_env(&extra, Some(parent), true) + .unwrap() + .unwrap(); + let v: serde_json::Value = serde_json::from_str(&merged).unwrap(); + // Both nested keys survive — no flat-spread drop. + assert_eq!( + v["sandbox_workspace_write"]["persona_only"], "keep_me", + "nested persona key must survive when parent has the same top-level key" + ); + assert_eq!( + v["sandbox_workspace_write"]["parent_only"], "also_here", + "nested parent key must be present" + ); + // Forced last. + assert_eq!( + v["sandbox_workspace_write"]["network_access"], true, + "network_access must be forced true" + ); + } + + #[test] + fn build_codex_config_env_parent_env_wins_on_collisions_persona_keys_survive() { + // Parent env has CODEX_CONFIG with some keys; persona has different keys. + // Parent wins on collision; unrelated persona keys survive. + // network_access is always forced true. + let persona_cfg = r#"{"persona_key":"persona_val","shared_key":"persona_version"}"#; + // Config::from_args appends generated AFTER persona env vars. + let extra = env(&[("CODEX_CONFIG", persona_cfg), ("CODEX_CONFIG", GENERATED)]); + let parent = r#"{"parent_key":"parent_val","shared_key":"parent_version"}"#; + let merged = build_codex_config_env(&extra, Some(parent), true) + .unwrap() + .unwrap(); + let v: serde_json::Value = serde_json::from_str(&merged).unwrap(); + // Parent-only key present + assert_eq!( + v["parent_key"], "parent_val", + "parent-only key must be present" + ); + // Unrelated persona key survives (no collision with parent) + assert_eq!( + v["persona_key"], "persona_val", + "unrelated persona key must survive" + ); + // Collision: parent wins + assert_eq!( + v["shared_key"], "parent_version", + "parent must win on colliding key" + ); + // network_access always true (forced last) + assert_eq!(v["sandbox_workspace_write"]["network_access"], true); + } + + #[test] + fn build_codex_config_env_parent_has_existing_sandbox_other_keys_survive() { + // Parent env has sandbox_workspace_write with extra keys; after merge + // those extra keys survive alongside network_access=true. + let persona = r#"{}"#; + let extra = env(&[("CODEX_CONFIG", persona), ("CODEX_CONFIG", GENERATED)]); + let parent = + r#"{"sandbox_workspace_write":{"network_access":false,"other_sandbox_key":"val"}}"#; + let merged = build_codex_config_env(&extra, Some(parent), true) + .unwrap() + .unwrap(); + let v: serde_json::Value = serde_json::from_str(&merged).unwrap(); + // network_access forced true even though parent set false + assert_eq!(v["sandbox_workspace_write"]["network_access"], true); + // other_sandbox_key survives (parent's sws merged, then network_access forced) + assert_eq!(v["sandbox_workspace_write"]["other_sandbox_key"], "val"); + } + + #[test] + fn build_codex_config_env_errors_on_invalid_persona_json() { + // Bad persona JSON + generated overlay, signal=true → parse error before merging. + let extra = env(&[("CODEX_CONFIG", "not-json"), ("CODEX_CONFIG", GENERATED)]); + let result = build_codex_config_env(&extra, None, true); + assert!(result.is_err(), "invalid persona JSON must return Err"); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("CODEX_CONFIG"), + "error must mention CODEX_CONFIG" + ); + } + + #[test] + fn build_codex_config_env_errors_on_non_object_persona_json() { + // Non-object persona JSON + generated overlay, signal=true → parse error. + let extra = env(&[("CODEX_CONFIG", "[1,2,3]"), ("CODEX_CONFIG", GENERATED)]); + let result = build_codex_config_env(&extra, None, true); + assert!(result.is_err(), "non-object persona JSON must return Err"); + } + + #[test] + fn build_codex_config_env_errors_on_invalid_parent_json() { + let persona = r#"{}"#; + let extra = env(&[("CODEX_CONFIG", persona), ("CODEX_CONFIG", GENERATED)]); + let result = build_codex_config_env(&extra, Some("bad-json"), true); + assert!(result.is_err(), "invalid parent env JSON must return Err"); + } + + #[test] + fn build_codex_config_env_errors_on_non_object_sandbox_workspace_write() { + // sandbox_workspace_write must be an object for network_access forcing. + // If the parent env sets it to a non-object scalar, deep_merge replaces + // our object with the scalar, and the force step must fail clearly. + let persona = r#"{}"#; + let extra = env(&[("CODEX_CONFIG", persona), ("CODEX_CONFIG", GENERATED)]); + // Parent replaces the object with a scalar — deep_merge: scalar overlay wins. + let parent = r#"{"sandbox_workspace_write": 42}"#; + let result = build_codex_config_env(&extra, Some(parent), true); + assert!( + result.is_err(), + "non-object sandbox_workspace_write must return Err" + ); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("sandbox_workspace_write"), + "error must mention sandbox_workspace_write" + ); + } } diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index fe2ea7107b..dea0e415fb 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -6,6 +6,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | Group | Key commands | |-------|-------------| +| `buzz agents` | `draft-create`, `draft-update` | | `buzz messages` | `send`, `get`, `thread`, `search` | | `buzz channels` | `list`, `get`, `create`, `join`, `members` | | `buzz canvas` | `get`, `set` | @@ -18,7 +19,17 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz repos` | `create`, `get`, `list` | | `buzz upload` | `file` | -Run `buzz --help` or `buzz --help` for full usage. +Run `buzz --help` or `buzz --help` for full usage. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. + +## Conversational Agent Creation + +When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous. + +`buzz agents draft-create --channel --display-name --system-prompt ` + +Use the channel UUID from `[Context]`. Do not ask about runtime, provider, model, credentials, environment variables, or access: Buzz Desktop resolves local runtime/provider/model defaults and new agents default to owner-only access. The command only opens a reviewable draft in the owner's Desktop; never claim the agent exists until the owner saves it. + +For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. Draft updates also require owner review and save. ## Communication Patterns @@ -47,7 +58,7 @@ All replies and delegations — including task assignments to other agents — g ### General - Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need. -- **Every turn that processes a user message MUST end with `buzz messages send`.** Your reasoning and tool calls are invisible to users — if you didn't send a message, they saw nothing. A turn that ends without a sent message is a silent failure. +- **Every turn that processes a user message MUST publish a reply.** Use the dedicated `buzz_send_message` tool when available; otherwise use `buzz messages send`. Your reasoning and other tool calls are invisible to users — if you didn't publish a message, they saw nothing. A turn that ends without a published message is a silent failure. - For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message. - Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting. - No push notifications — poll with `buzz messages get --channel --since `. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index d2c111fbae..ca3b35413a 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -26,6 +26,15 @@ use crate::filter::SubscriptionRule; /// Override via `--idle-timeout` / `BUZZ_ACP_IDLE_TIMEOUT`. pub(crate) const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 900; +/// Default absolute wall-clock cap per agent turn (2 hours). +/// Override via `--max-turn-duration` / `BUZZ_ACP_MAX_TURN_DURATION`. +pub(crate) const DEFAULT_MAX_TURN_DURATION_SECS: u64 = 7200; + +/// Upper bound for `max_turn_duration` (7 days). Any higher is operationally +/// meaningless and risks arithmetic overflow when deriving the in-flight +/// deadline (`max_turn_duration + IN_FLIGHT_DEADLINE_BUFFER_SECS`). +pub(crate) const MAX_TURN_DURATION_CEILING_SECS: u64 = 604_800; + #[derive(Debug, Error)] pub enum ConfigError { #[error("failed to parse nostr keys: {0}")] @@ -220,7 +229,7 @@ pub struct CliArgs { pub idle_timeout: Option, /// Absolute wall-clock cap per turn (safety valve). - #[arg(long, env = "BUZZ_ACP_MAX_TURN_DURATION", default_value = "3600")] + #[arg(long, env = "BUZZ_ACP_MAX_TURN_DURATION", default_value_t = DEFAULT_MAX_TURN_DURATION_SECS)] pub max_turn_duration: u64, /// Deprecated: alias for --idle-timeout. If both set, --idle-timeout wins. @@ -413,14 +422,9 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')] pub allowed_respond_to: Option>, - /// Path to a persona pack directory. Used with --persona-name to configure - /// the agent from a .persona.md pack instead of CLI flags. - #[arg(long, env = "BUZZ_ACP_PERSONA_PACK")] - pub persona_pack: Option, - - /// Name of the persona within the pack to use. Required when --persona-pack is set. - #[arg(long, env = "BUZZ_ACP_PERSONA_NAME")] - pub persona_name: Option, + /// Team-owned instructions layered after `[System]` and before agent memory. + #[arg(long, env = "BUZZ_ACP_TEAM_INSTRUCTIONS")] + pub team_instructions: Option, /// Publish encrypted ACP observer frames over the relay. #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] @@ -453,6 +457,8 @@ pub struct Config { pub turn_liveness_secs: u64, pub heartbeat_prompt: Option, pub system_prompt: Option, + /// Team-owned instructions layered separately from the agent system prompt. + pub team_instructions: Option, pub initial_message: Option, pub subscribe_mode: SubscribeMode, pub dedup_mode: DedupMode, @@ -485,6 +491,12 @@ pub struct Config { /// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL, BUZZ_AGENT_MODEL). /// Populated from persona pack resolution. Empty when no pack is configured. pub persona_env_vars: Vec<(String, String)>, + /// Whether `codex_network_env()` successfully injected a `CODEX_CONFIG` entry into + /// `persona_env_vars`. When true, `AcpClient::spawn` merges all `CODEX_CONFIG` entries + /// and forces `sandbox_workspace_write.network_access = true` via `build_codex_config_env`. + /// When false (non-Codex agents or rejected relay URL), the helper returns None and + /// any persona-supplied `CODEX_CONFIG` is handled with ordinary operator-wins semantics. + pub has_generated_codex_config: bool, /// Whether to publish encrypted observer frames through the relay. pub relay_observer: bool, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. @@ -569,62 +581,57 @@ fn default_agent_args(command: &str) -> Option> { } } -/// Build `-c` flag pairs that enable network access and allowlist the relay hostname -/// in Codex's network sandbox. +/// Build the `CODEX_CONFIG` environment variable that enables full outbound +/// network access in Codex's macOS Seatbelt sandbox. /// /// Codex sandboxes MCP subprocesses (including `buzz-cli`) behind a Seatbelt sandbox -/// that blocks all outbound network by default, plus a managed proxy with a domain -/// allowlist. Without these flags, `buzz-cli` requests to the relay are blocked before -/// they reach WARP or any other outbound network path. +/// that blocks all outbound network by default. Without this env var, `buzz-cli` +/// requests are blocked before they can reach the relay WebSocket. /// -/// Returns `["-c", "sandbox_workspace_write.network_access=true", "-c", -/// "network_proxy.mode=\"full\"", "-c", "network_proxy.domains.\"\"=\"allow\""]` -/// for Codex agents, or an empty vec for non-Codex agents or when the hostname cannot -/// be parsed from the relay URL. +/// Returns `Some(("CODEX_CONFIG", "{\"sandbox_workspace_write\":{\"network_access\":true}}"))` for +/// Codex agents, or `None` for non-Codex agents or when the relay URL cannot be parsed. /// -/// The flags form a layered defense: -/// - `sandbox_workspace_write.network_access=true` opens the Seatbelt sandbox gate so -/// outbound TCP/TLS connections are allowed at the OS level -/// - `network_proxy.mode="full"` enables the managed proxy for all outbound traffic -/// - `network_proxy.domains.""="allow"` adds the relay hostname to the allowlist +/// The env var is forwarded by the `@agentclientprotocol/codex-acp` adapter (1.x) as a +/// session-level config override (via `CODEX_CONFIG` → `thread/start config`), which is +/// equivalent to the TOML override `sandbox_workspace_write.network_access = true`. +/// That sets `NetworkSandboxPolicy::Enabled`, causing the Seatbelt policy to include +/// `(allow network-outbound)` — full outbound TCP/TLS at the OS level. /// -/// Handles `ws://`, `wss://`, `http://`, and `https://` schemes. Port is stripped — -/// Codex's domain allowlist matches on hostname only. -pub fn codex_network_args(agent_command: &str, relay_url: &str) -> Vec { +/// URL validation is preserved as a guard: injection is skipped when the relay URL cannot +/// be parsed, avoiding accidental sandbox widening for malformed configs. +/// +/// Handles `ws://`, `wss://`, `http://`, and `https://` schemes. +pub fn codex_network_env(agent_command: &str, relay_url: &str) -> Option<(String, String)> { match normalize_agent_command_identity(agent_command).as_str() { "codex" | "codex-acp" => {} - _ => return vec![], + _ => return None, } - // Use the `url` crate so ws://, wss://, http://, https:// are all handled - // correctly. On parse failure, skip injection rather than panicking. + // Validate the relay URL before injecting broader network access. On parse failure, + // skip injection rather than panicking or widening the sandbox unconditionally. let host = match Url::parse(relay_url) { Ok(u) => match u.host_str() { Some(h) => h.to_owned(), None => { tracing::warn!( relay_url, - "codex network allowlist: no host in relay URL — skipping injection" + "codex network config: no host in relay URL — skipping injection" ); - return vec![]; + return None; } }, Err(e) => { - tracing::warn!(relay_url, error = %e, "codex network allowlist: failed to parse relay URL — skipping injection"); - return vec![]; + tracing::warn!(relay_url, error = %e, "codex network config: failed to parse relay URL — skipping injection"); + return None; } }; - tracing::debug!(host, "injecting codex network allowlist for host"); + tracing::debug!(host, "injecting CODEX_CONFIG network_access for relay host"); - vec![ - "-c".into(), - "sandbox_workspace_write.network_access=true".into(), - "-c".into(), - "network_proxy.mode=\"full\"".into(), - "-c".into(), - format!("network_proxy.domains.\"{host}\"=\"allow\""), - ] + Some(( + "CODEX_CONFIG".into(), + "{\"sandbox_workspace_write\":{\"network_access\":true}}".into(), + )) } pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { @@ -697,7 +704,7 @@ impl Config { .replace_range(.., &"0".repeat(args.private_key.len())); args.private_key.clear(); - let mut system_prompt = if let Some(text) = args.system_prompt { + let system_prompt = if let Some(text) = args.system_prompt { Some(text) } else if let Some(ref path) = args.system_prompt_file { Some(std::fs::read_to_string(path)?) @@ -762,16 +769,7 @@ impl Config { )); } - let mut agent_args = normalize_agent_args(&agent_command, args.agent_args); - - // Prepend Codex network allowlist flags so buzz-cli (an MCP subprocess) - // can reach the relay through Codex's sandbox proxy. No-op for non-Codex agents. - let network_args = codex_network_args(&agent_command, &args.relay_url); - if !network_args.is_empty() { - let mut merged = network_args; - merged.extend(agent_args); - agent_args = merged; - } + let agent_args = normalize_agent_args(&agent_command, args.agent_args); // Finding #49b — warn on invalid UUIDs in --channels. if let Some(ref channels) = args.channels { @@ -841,6 +839,11 @@ impl Config { if raw == 0 { tracing::warn!("max turn duration of 0 is invalid — using 60s minimum"); 60 + } else if raw > MAX_TURN_DURATION_CEILING_SECS { + return Err(ConfigError::ConfigFile(format!( + "max_turn_duration ({}s) exceeds ceiling ({}s / 7 days)", + raw, MAX_TURN_DURATION_CEILING_SECS + ))); } else { raw } @@ -898,52 +901,21 @@ impl Config { Vec::new() }; - // - // Precedence: CLI/env args > persona values > built-in defaults. - // Persona fills in what's missing. Explicit flags always win. - let (persona_system_prompt, persona_model, persona_env_vars) = - match (&args.persona_pack, &args.persona_name) { - (Some(pack_dir), Some(name)) => { - let pack = buzz_persona::resolve::resolve_pack(pack_dir).map_err(|e| { - ConfigError::ConfigFile(format!( - "failed to resolve pack {}: {e}", - pack_dir.display() - )) - })?; - let persona = pack - .personas - .into_iter() - .find(|p| p.name == *name) - .ok_or_else(|| { - ConfigError::ConfigFile(format!( - "persona '{name}' not found in pack {}", - pack_dir.display() - )) - })?; - ( - Some(persona.system_prompt), - persona.model, - persona.runtime_env_vars, - ) - } - (Some(_), None) => { - return Err(ConfigError::ConfigFile( - "--persona-pack requires --persona-name".into(), - )); - } - (None, Some(_)) => { - return Err(ConfigError::ConfigFile( - "--persona-name requires --persona-pack".into(), - )); - } - (None, None) => (None, None, vec![]), - }; + // Spawned desktop agents now carry a complete instance snapshot. Team + // instructions arrive independently so they can be layered at runtime. + let mut persona_env_vars = Vec::new(); + let model = args.model; - // Apply persona defaults: CLI/env wins, persona fills gaps. - if system_prompt.is_none() { - system_prompt = persona_system_prompt; - } - let model = args.model.or(persona_model); + // Inject CODEX_CONFIG so the @agentclientprotocol/codex-acp adapter (1.x) + // opens the Seatbelt network sandbox for buzz-cli (an MCP subprocess). No-op + // for non-Codex agents or unparseable relay URLs. + let has_generated_codex_config = + if let Some(network_env) = codex_network_env(&agent_command, &args.relay_url) { + persona_env_vars.push(network_env); + true + } else { + false + }; validate_multiple_event_handling(args.multiple_event_handling, args.dedup)?; @@ -960,6 +932,12 @@ impl Config { turn_liveness_secs, heartbeat_prompt, system_prompt, + team_instructions: args + .team_instructions + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), initial_message: args.initial_message, subscribe_mode: args.subscribe, dedup_mode: args.dedup, @@ -980,6 +958,7 @@ impl Config { respond_to_allowlist, allowed_respond_to, persona_env_vars, + has_generated_codex_config, relay_observer: args.relay_observer, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, @@ -1324,12 +1303,13 @@ mod tests { agent_args: vec!["acp".into()], mcp_command: "".into(), idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS, - max_turn_duration_secs: 3600, + max_turn_duration_secs: DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, heartbeat_interval_secs: 0, turn_liveness_secs: 10, heartbeat_prompt: None, system_prompt: None, + team_instructions: None, initial_message: None, subscribe_mode: mode, dedup_mode: DedupMode::Queue, @@ -1350,6 +1330,7 @@ mod tests { respond_to_allowlist: HashSet::new(), allowed_respond_to: Vec::new(), persona_env_vars: vec![], + has_generated_codex_config: false, relay_observer: false, agent_owner: None, no_base_prompt: false, @@ -1530,133 +1511,100 @@ mod tests { ); } - // --- codex_network_args tests --- + // --- codex_network_env tests --- + + const CODEX_CONFIG_JSON: &str = "{\"sandbox_workspace_write\":{\"network_access\":true}}"; #[test] - fn codex_network_args_wss_url() { - let args = codex_network_args("codex-acp", "wss://sprout-oss.stage.blox.sqprod.co"); + fn codex_network_env_wss_url() { + let result = codex_network_env("codex-acp", "wss://sprout-oss.stage.blox.sqprod.co"); assert_eq!( - args, - vec![ - "-c", - "sandbox_workspace_write.network_access=true", - "-c", - "network_proxy.mode=\"full\"", - "-c", - "network_proxy.domains.\"sprout-oss.stage.blox.sqprod.co\"=\"allow\"", - ] + result, + Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string())) ); } #[test] - fn codex_network_args_ws_url() { - let args = codex_network_args("codex-acp", "ws://localhost:3000"); + fn codex_network_env_ws_url() { + let result = codex_network_env("codex-acp", "ws://localhost:3000"); assert_eq!( - args, - vec![ - "-c", - "sandbox_workspace_write.network_access=true", - "-c", - "network_proxy.mode=\"full\"", - "-c", - "network_proxy.domains.\"localhost\"=\"allow\"", - ] + result, + Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string())) ); } #[test] - fn codex_network_args_https_url() { - let args = codex_network_args("codex-acp", "https://relay.example.com/path"); + fn codex_network_env_https_url() { + let result = codex_network_env("codex-acp", "https://relay.example.com/path"); assert_eq!( - args, - vec![ - "-c", - "sandbox_workspace_write.network_access=true", - "-c", - "network_proxy.mode=\"full\"", - "-c", - "network_proxy.domains.\"relay.example.com\"=\"allow\"", - ] + result, + Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string())) ); } #[test] - fn codex_network_args_http_url_with_port() { - let args = codex_network_args("codex-acp", "http://relay.example.com:8080/query"); + fn codex_network_env_http_url_with_port() { + let result = codex_network_env("codex-acp", "http://relay.example.com:8080/query"); assert_eq!( - args, - vec![ - "-c", - "sandbox_workspace_write.network_access=true", - "-c", - "network_proxy.mode=\"full\"", - "-c", - "network_proxy.domains.\"relay.example.com\"=\"allow\"", - ] + result, + Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string())) ); } #[test] - fn codex_network_args_bare_codex_command() { - // "codex" (not "codex-acp") should also get the args. - let args = codex_network_args("codex", "wss://relay.example.com"); + fn codex_network_env_bare_codex_command() { + // "codex" (not "codex-acp") should also get the env var. + let result = codex_network_env("codex", "wss://relay.example.com"); assert_eq!( - args, - vec![ - "-c", - "sandbox_workspace_write.network_access=true", - "-c", - "network_proxy.mode=\"full\"", - "-c", - "network_proxy.domains.\"relay.example.com\"=\"allow\"", - ] + result, + Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string())) ); } #[test] - fn codex_network_args_full_path_codex_command() { + fn codex_network_env_full_path_codex_command() { // Full path like /usr/local/bin/codex-acp should be normalized. - let args = codex_network_args("/usr/local/bin/codex-acp", "wss://relay.example.com"); + let result = codex_network_env("/usr/local/bin/codex-acp", "wss://relay.example.com"); assert_eq!( - args, - vec![ - "-c", - "sandbox_workspace_write.network_access=true", - "-c", - "network_proxy.mode=\"full\"", - "-c", - "network_proxy.domains.\"relay.example.com\"=\"allow\"", - ] + result, + Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string())) ); } #[test] - fn codex_network_args_non_codex_agent_returns_empty() { - assert!(codex_network_args("goose", "wss://relay.example.com").is_empty()); - assert!(codex_network_args("claude-agent-acp", "wss://relay.example.com").is_empty()); - assert!(codex_network_args("buzz-agent", "wss://relay.example.com").is_empty()); + fn codex_network_env_non_codex_agent_returns_none() { + assert!(codex_network_env("goose", "wss://relay.example.com").is_none()); + assert!(codex_network_env("claude-agent-acp", "wss://relay.example.com").is_none()); + assert!(codex_network_env("buzz-agent", "wss://relay.example.com").is_none()); } #[test] - fn codex_network_args_includes_sandbox_network_access() { - // The sandbox gate must be the first flag pair — without it, the Seatbelt - // sandbox blocks outbound connections before the proxy can intercept them. - let args = codex_network_args("codex-acp", "wss://relay.example.com"); - assert_eq!(args.len(), 6, "expected 3 flag pairs (6 elements)"); - assert_eq!(args[0], "-c"); - assert_eq!(args[1], "sandbox_workspace_write.network_access=true"); + fn codex_network_env_includes_sandbox_network_access() { + // The JSON value must set sandbox_workspace_write.network_access=true — without + // it, the Seatbelt sandbox blocks outbound connections in the 1.x adapter. + let result = codex_network_env("codex-acp", "wss://relay.example.com"); + let (key, val) = result.expect("expected Some for valid codex + valid url"); + assert_eq!(key, "CODEX_CONFIG"); + assert!( + val.contains("\"sandbox_workspace_write\""), + "JSON must contain sandbox_workspace_write" + ); + assert!( + val.contains("\"network_access\":true"), + "JSON must set network_access=true" + ); } #[test] - fn codex_network_args_empty_relay_url_returns_empty() { - // Empty string fails Url::parse — graceful empty return. - assert!(codex_network_args("codex-acp", "").is_empty()); + fn codex_network_env_empty_relay_url_returns_none() { + // Empty string fails Url::parse — graceful None return. + assert!(codex_network_env("codex-acp", "").is_none()); } #[test] - fn codex_network_args_schemeless_string_returns_empty() { - // A bare string with no scheme fails Url::parse — graceful empty return. - assert!(codex_network_args("codex-acp", "not-a-url").is_empty()); + fn codex_network_env_schemeless_string_returns_none() { + // A bare string with no scheme fails Url::parse — graceful None return. + assert!(codex_network_env("codex-acp", "not-a-url").is_none()); } #[test] @@ -2279,7 +2227,7 @@ channels = "ALL" "summary should include {expected_idle}: {summary}" ); assert!( - summary.contains("max_turn=3600s"), + summary.contains(&format!("max_turn={DEFAULT_MAX_TURN_DURATION_SECS}s")), "summary should include max_turn: {summary}" ); } @@ -2484,13 +2432,10 @@ channels = "ALL" "test precondition: idle must be >= max_turn to trigger guard" ); - // And the valid case: - let idle_valid = 900u64; - let max_turn_valid = 3600u64; - assert!( - idle_valid < max_turn_valid, - "default idle (900) must be less than default max_turn (3600)" - ); + // And the valid case (const assertion so clippy doesn't flag it): + const { + assert!(DEFAULT_IDLE_TIMEOUT_SECS < DEFAULT_MAX_TURN_DURATION_SECS); + } } // --- BUZZ_ACP_ALLOWED_RESPOND_TO gate --- @@ -2675,4 +2620,57 @@ channels = "ALL" "from_args should accept any mode when allowed list is unset: {result:?}" ); } + + // --- max_turn_duration ceiling gate --- + + #[test] + fn max_turn_duration_at_ceiling_is_accepted() { + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--max-turn-duration", + &MAX_TURN_DURATION_CEILING_SECS.to_string(), + ]) + .expect("clap should parse args"); + let result = Config::from_args(args); + + assert!( + result.is_ok(), + "from_args should accept max_turn_duration at the ceiling: {result:?}" + ); + } + + #[test] + fn max_turn_duration_above_ceiling_is_rejected() { + let over = MAX_TURN_DURATION_CEILING_SECS + 1; + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--max-turn-duration", + &over.to_string(), + ]) + .expect("clap should parse args"); + let result = Config::from_args(args); + + assert!( + result.is_err(), + "from_args should reject max_turn_duration above the ceiling" + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("exceeds ceiling"), + "error should mention 'exceeds ceiling': {msg}" + ); + } + + #[test] + fn max_turn_duration_ceiling_cannot_overflow_in_flight_deadline() { + // The in-flight deadline is max_turn + 100s buffer (IN_FLIGHT_DEADLINE_BUFFER_SECS). + // Verify that even at the ceiling, this addition cannot overflow u64. + const { + assert!(MAX_TURN_DURATION_CEILING_SECS < u64::MAX - 100); + } + } } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 4a7fdc8514..77fb4a6138 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -8,6 +8,7 @@ mod observer; mod pool; mod queue; mod relay; +mod setup_mode; mod usage; pub use usage::TurnUsage; @@ -33,9 +34,9 @@ use futures_util::FutureExt; use nostr::{PublicKey, ToBech32}; use pool::{ AgentPool, ControlSignal, IdleSwitchResult, OwnedAgent, PromptContext, PromptOutcome, - PromptResult, PromptSource, SessionState, + PromptResult, PromptSource, SessionState, TimeoutKind, }; -use queue::{CancelReason, EventQueue, QueuedEvent, ThreadTags}; +use queue::{CancelReason, EventQueue, FlushBatch, QueuedEvent, ThreadTags}; use relay::{HarnessRelay, RelayEventPublisher}; use tokio::sync::{mpsc, watch}; use tracing_subscriber::EnvFilter; @@ -1094,6 +1095,19 @@ async fn tokio_main() -> Result<()> { .init(); let mut config = Config::from_cli().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?; + + // ── Setup-mode early branch ─────────────────────────────────────────────── + // + // When the desktop determines an agent is not ready (missing credentials, + // model, or provider), it spawns buzz-acp with BUZZ_ACP_SETUP_PAYLOAD set. + // We enter the minimal setup-listener path and never start the agent pool. + if let Some(payload) = setup_mode::SetupPayload::from_env() + .map_err(|e| anyhow::anyhow!("setup payload error: {e}"))? + { + tracing::info!("buzz-acp: setup payload present, entering setup-listener mode"); + return setup_mode::run_setup_listener(config, payload).await; + } + tracing::info!("buzz-acp starting: {}", config.summary()); let observer = config @@ -1129,6 +1143,7 @@ async fn tokio_main() -> Result<()> { &config.agent_command, &config.agent_args, &config.persona_env_vars, + config.has_generated_codex_config, ) .await; match spawn_result { @@ -1372,7 +1387,8 @@ async fn tokio_main() -> Result<()> { } let dedup_mode = config.dedup_mode; - let mut queue = EventQueue::new(dedup_mode); + let mut queue = + EventQueue::new(dedup_mode).with_in_flight_deadline(config.max_turn_duration_secs); let base_prompt_content = config.base_prompt_content.take(); let ctx = Arc::new(PromptContext { @@ -1383,6 +1399,7 @@ async fn tokio_main() -> Result<()> { turn_liveness_interval: Duration::from_secs(config.turn_liveness_secs), dedup_mode: config.dedup_mode, system_prompt: config.system_prompt.clone(), + team_instructions: config.team_instructions.clone(), base_prompt: if config.no_base_prompt { None } else if let Some(content) = base_prompt_content { @@ -1567,10 +1584,11 @@ async fn tokio_main() -> Result<()> { let cmd = config.agent_command.clone(); let args = config.agent_args.clone(); let env = config.persona_env_vars.clone(); + let has_codex = config.has_generated_codex_config; let observer = observer.clone(); let guard = RespawnGuard::new(idx, respawn_tx.clone()); respawn_tasks.spawn(async move { - let result = spawn_and_init(&cmd, &args, &env, idx, observer).await; + let result = spawn_and_init(&cmd, &args, &env, has_codex, idx, observer).await; guard.send(result); }); } @@ -2655,6 +2673,29 @@ fn dispatch_pending( dispatched_channels } +/// Spawn a task that posts a user-visible failure notice to the relay. +/// +/// Shared by the hard-cap immediate dead-letter path and the retries-exhausted +/// dead-letter path so neither duplicates the tokio::spawn block. +fn spawn_failure_notice( + rest_client: Option<&relay::RestClient>, + batch: &FlushBatch, + content: String, +) { + if let Some(rest) = rest_client { + let thread_tags = batch + .events + .last() + .map(|be| queue::parse_thread_tags(&be.event)) + .unwrap_or_default(); + let rest = rest.clone(); + let channel_id = batch.channel_id; + tokio::spawn(async move { + pool::post_failure_notice(&rest, channel_id, &thread_tags, &content).await; + }); + } +} + #[allow(clippy::too_many_arguments)] fn handle_prompt_result( pool: &mut AgentPool, @@ -2684,7 +2725,10 @@ fn handle_prompt_result( // Don't requeue batches for channels the agent was removed from — // those events are stale and should be silently dropped. if !removed_channels.contains(&batch.channel_id) { - if matches!(result.outcome, PromptOutcome::Cancelled) { + if matches!( + result.outcome, + PromptOutcome::Cancelled | PromptOutcome::CancelDrainTimeout(_) + ) { // Cancel re-prompt: store as cancelled events so flush_next() // merges them into the next FlushBatch.cancelled_events, // enabling the annotated merged-prompt format. The batch's @@ -2693,33 +2737,50 @@ fn handle_prompt_result( // path; if somehow unset, fall back to the gentler Steer framing // — consistent with MergeFraming::for_reason(None) and the // system default — rather than telling the agent to supersede. + // + // CancelDrainTimeout shares this path with Cancelled: a failed + // 5s drain after a control-signal cancel is a cleanup-deadline + // problem, not the deterministic hard-cap death below — the + // original batch must survive with no retry/dead-letter + // accounting, same as a clean cancel. let reason = batch.cancel_reason.unwrap_or(CancelReason::Steer); queue.requeue_as_cancelled(batch, reason); + } else if matches!(result.outcome, PromptOutcome::Timeout(TimeoutKind::Hard)) { + // Hard-cap timeout is deterministic: re-running the same task + // from a fresh session will reproduce the same death. Dead-letter + // immediately without requeueing so the channel isn't subjected to + // up to 10 × 1-hour retry cycles. + tracing::error!( + channel_id = %batch.channel_id, + events = batch.events.len(), + "dead-lettering batch after hard-cap timeout — discarding {} events", + batch.events.len(), + ); + let content = format!( + "⚠️ I couldn't process the last request (the turn exceeded the maximum duration ({}s)). Please re-send if it's still needed.", + config.max_turn_duration_secs + ); + spawn_failure_notice(rest_client, &batch, content); } else if let Some(dead) = queue.requeue(batch) { // Dead-lettered: retries exhausted and the events are gone. // Post a visible notice so the channel isn't left waiting on // a turn that will never happen. - if let Some(rest) = rest_client { - let thread_tags = dead - .events - .last() - .map(|be| queue::parse_thread_tags(&be.event)) - .unwrap_or_default(); - let reason = match &result.outcome { - PromptOutcome::Timeout => "the turn timed out".to_string(), - PromptOutcome::AgentExited => "the agent process exited".to_string(), - PromptOutcome::Error(e) => format!("{e}"), - _ => "repeated failures".to_string(), - }; - let content = format!( - "⚠️ I couldn't process the last request after multiple retries ({reason}). Please re-send if it's still needed." - ); - let rest = rest.clone(); - let channel_id = dead.channel_id; - tokio::spawn(async move { - pool::post_failure_notice(&rest, channel_id, &thread_tags, &content).await; - }); - } + let reason = match &result.outcome { + PromptOutcome::Timeout(TimeoutKind::Idle) => "the turn timed out".to_string(), + // Unreachable today: Timeout(Hard) is consumed by the immediate + // dead-letter arm above before requeue() runs. Fail soft rather + // than panicking the main loop if that chain is ever reordered. + PromptOutcome::Timeout(TimeoutKind::Hard) => { + "the turn exceeded the maximum duration".to_string() + } + PromptOutcome::AgentExited => "the agent process exited".to_string(), + PromptOutcome::Error(e) => format!("{e}"), + _ => "repeated failures".to_string(), + }; + let content = format!( + "⚠️ I couldn't process the last request after multiple retries ({reason}). Please re-send if it's still needed." + ); + spawn_failure_notice(rest_client, &dead, content); } } else { tracing::debug!( @@ -2745,26 +2806,45 @@ fn handle_prompt_result( let outcome_label = match &result.outcome { PromptOutcome::Ok(_) => "ok", PromptOutcome::Error(_) => "error", - PromptOutcome::Timeout => "timeout", + PromptOutcome::Timeout(TimeoutKind::Idle) => "idle_timeout", + PromptOutcome::Timeout(TimeoutKind::Hard) => "hard_timeout", PromptOutcome::AgentExited => "exited", PromptOutcome::Cancelled => "cancelled", + PromptOutcome::CancelDrainTimeout(_) => "cancel_drain_timeout", }; let agent_index = result.agent.index; + // Capture the spawn-time configured model and our PID before the agent is + // moved into match arms below. `desired_model` reflects the config/persona + // model at spawn time — it does NOT reflect `session/set_model` overrides, + // which live in buzz-agent's session state and are what `llm: (model) …` + // errors carry. The two can legitimately differ; `configured_model=` is + // still valuable for identifying a stale orphan running an old model. + let harness_configured_model = result + .agent + .desired_model + .as_deref() + .unwrap_or("") + .to_string(); + let harness_pid = std::process::id(); let channel_id = match &result.source { PromptSource::Channel(ch) => Some(*ch), PromptSource::Heartbeat => None, }; - let emit_turn_error = |error_msg: &str| { + let emit_turn_error = |error_msg: &str, error_code: Option| { if let Some(ref observer) = observer { + let mut payload = serde_json::json!({ + "outcome": outcome_label, + "error": error_msg, + }); + if let Some(code) = error_code { + payload["code"] = serde_json::json!(code); + } observer.emit( "turn_error", Some(agent_index), &observer::context_for(channel_id, None, None), - serde_json::json!({ - "outcome": outcome_label, - "error": error_msg, - }), + payload, ); } }; @@ -2780,17 +2860,63 @@ fn handle_prompt_result( pool.return_agent(result.agent); } // Fatal outcomes: the agent subprocess is dead or poisoned — respawn it. - PromptOutcome::AgentExited | PromptOutcome::Timeout => { + PromptOutcome::AgentExited | PromptOutcome::Timeout(_) => { tracing::warn!( agent = agent_index, outcome = outcome_label, + configured_model = %harness_configured_model, + pid = harness_pid, "agent_returned — respawning" ); - let death_message = match outcome_label { - "exited" => "Agent process exited unexpectedly", - _ => "Agent session timed out due to inactivity", + let death_message: String = match outcome_label { + "exited" => "Agent process exited unexpectedly".to_string(), + "hard_timeout" => format!( + "Agent turn exceeded the maximum duration ({}s)", + config.max_turn_duration_secs + ), + _ => "Agent session timed out due to inactivity".to_string(), }; - emit_turn_error(death_message); + emit_turn_error(&death_message, None); + + let index = result.agent.index; + let slot_history = &mut crash_history[index]; + if !spawn_respawn_task( + result.agent, + config, + slot_history, + respawn_tx, + respawn_tasks, + observer.clone(), + ) { + // Circuit open — slot stays empty until maintenance refill. + if pool.live_count() == 0 && !any_respawn_in_flight(crash_history) { + tracing::error!("all agents dead — exiting"); + return LoopAction::Exit; + } + } + } + // Cancel-drain expiry: a control-signal cancel (steer fallback, + // interrupt, or explicit stop) did not drain within its bounded + // grace window. The process is poisoned/uncertain like a hard + // timeout — respawn it — but this is NOT the configured max-turn + // cap, so the message must name the actual grace, not + // `max_turn_duration_secs`. The triggering batch's fate (preserved + // for Steer/Interrupt, dropped for explicit Cancel/Rotate or a + // removed channel) is decided above — the message stays fate-neutral + // since it must be true in every case. + PromptOutcome::CancelDrainTimeout(grace) => { + tracing::warn!( + agent = agent_index, + outcome = outcome_label, + configured_model = %harness_configured_model, + pid = harness_pid, + grace = ?grace, + "agent_returned — respawning (cancel-drain timeout)" + ); + let death_message = format!( + "Agent did not stop within {grace:?} after cancellation; the agent process is being replaced." + ); + emit_turn_error(&death_message, None); let index = result.agent.index; let slot_history = &mut crash_history[index]; @@ -2828,6 +2954,8 @@ fn handle_prompt_result( tracing::debug!( agent = agent_index, outcome = outcome_label, + configured_model = %harness_configured_model, + pid = harness_pid, "agent_returned (cancelled)" ); pool.return_agent(result.agent); @@ -2840,14 +2968,20 @@ fn handle_prompt_result( | acp::AcpError::Timeout(_) | acp::AcpError::Protocol(_) ); + let error_code = match &e { + acp::AcpError::AgentError { code, .. } => Some(*code), + _ => None, + }; if is_transport_error { tracing::warn!( agent = agent_index, outcome = outcome_label, + configured_model = %harness_configured_model, + pid = harness_pid, error = %e, "transport/protocol error — respawning agent" ); - emit_turn_error(&e.to_string()); + emit_turn_error(&e.to_string(), error_code); let index = result.agent.index; let slot_history = &mut crash_history[index]; @@ -2868,10 +3002,12 @@ fn handle_prompt_result( tracing::warn!( agent = agent_index, outcome = outcome_label, + configured_model = %harness_configured_model, + pid = harness_pid, error = %e, "agent_returned (application error — pipe intact)" ); - emit_turn_error(&e.to_string()); + emit_turn_error(&e.to_string(), error_code); pool.return_agent(result.agent); } } @@ -2967,12 +3103,13 @@ fn recover_panicked_agent( let cmd = config.agent_command.clone(); let args = config.agent_args.clone(); let env = config.persona_env_vars.clone(); + let has_codex = config.has_generated_codex_config; let guard = RespawnGuard::new(i, respawn_tx.clone()); respawn_tasks.spawn(async move { if !delay.is_zero() { tokio::time::sleep(delay).await; } - let result = spawn_and_init(&cmd, &args, &env, i, observer).await; + let result = spawn_and_init(&cmd, &args, &env, has_codex, i, observer).await; guard.send(result); }); } @@ -3057,6 +3194,19 @@ fn dispatch_heartbeat( tracing::info!(agent = agent_index, "heartbeat_fired"); } +#[cfg(test)] +mod agent_draft_prompt_tests { + #[test] + fn shared_base_prompt_teaches_portable_agent_drafts() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("buzz agents draft-create")); + assert!(prompt.contains("ask for at most two things")); + assert!(prompt.contains("what it should do day-to-day")); + assert!(prompt.contains("owner saves it")); + assert!(prompt.contains("Do not ask about runtime, provider, model, credentials")); + } +} + fn default_heartbeat_prompt() -> String { let now = chrono::Utc::now().to_rfc3339(); format!( @@ -3115,6 +3265,7 @@ fn spawn_respawn_task( let cmd = config.agent_command.clone(); let args = config.agent_args.clone(); let env = config.persona_env_vars.clone(); + let has_codex = config.has_generated_codex_config; let guard = RespawnGuard::new(index, respawn_tx.clone()); respawn_tasks.spawn(async move { // Shutdown old agent (reap child, prevent zombie). @@ -3126,7 +3277,7 @@ fn spawn_respawn_task( tokio::time::sleep(delay).await; } - let result = spawn_and_init(&cmd, &args, &env, index, observer).await; + let result = spawn_and_init(&cmd, &args, &env, has_codex, index, observer).await; guard.send(result); }); @@ -3142,10 +3293,11 @@ async fn spawn_and_init( command: &str, args: &[String], extra_env: &[(String, String)], + has_generated_codex_config: bool, agent_index: usize, observer: Option, ) -> Result<(AcpClient, u32, bool)> { - let mut acp = AcpClient::spawn(command, args, extra_env) + let mut acp = AcpClient::spawn(command, args, extra_env, has_generated_codex_config) .await .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; acp.set_observer(observer, agent_index); @@ -3187,8 +3339,8 @@ async fn run_models(args: ModelsArgs) -> Result<()> { .to_string(); // Spawn outside the timeout so we always own the child for cleanup. - // `models` subcommand doesn't use persona packs — no extra env. - let mut client = match AcpClient::spawn(&args.agent_command, &agent_args, &[]).await { + // `models` subcommand doesn't use persona packs — no extra env, no codex config. + let mut client = match AcpClient::spawn(&args.agent_command, &agent_args, &[], false).await { Ok(c) => c, Err(e) => { eprintln!("error: failed to spawn agent: {e}"); @@ -3787,12 +3939,13 @@ mod build_mcp_servers_tests { agent_args: vec!["acp".into()], mcp_command: "test-mcp-server".into(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, - max_turn_duration_secs: 3600, + max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, heartbeat_interval_secs: 0, turn_liveness_secs: 10, heartbeat_prompt: None, system_prompt: None, + team_instructions: None, initial_message: None, subscribe_mode: config::SubscribeMode::All, dedup_mode: config::DedupMode::Queue, @@ -3813,6 +3966,7 @@ mod build_mcp_servers_tests { respond_to_allowlist: std::collections::HashSet::new(), allowed_respond_to: vec![], persona_env_vars: vec![], + has_generated_codex_config: false, relay_observer: false, agent_owner: None, no_base_prompt: false, @@ -3932,7 +4086,11 @@ mod error_outcome_emission_tests { use super::*; use crate::acp::{AcpClient, AcpError}; use crate::observer::ObserverHandle; - use crate::pool::{AgentPool, OwnedAgent, PromptOutcome, PromptResult, PromptSource}; + use crate::pool::{ + AgentPool, OwnedAgent, PromptOutcome, PromptResult, PromptSource, TimeoutKind, + }; + use crate::queue::{BatchEvent, FlushBatch}; + use nostr::{EventBuilder, Keys, Kind}; use std::collections::HashSet; fn test_config() -> Config { @@ -3946,12 +4104,13 @@ mod error_outcome_emission_tests { agent_args: vec![], mcp_command: "test-mcp-server".into(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, - max_turn_duration_secs: 3600, + max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, heartbeat_interval_secs: 0, turn_liveness_secs: 10, heartbeat_prompt: None, system_prompt: None, + team_instructions: None, initial_message: None, subscribe_mode: config::SubscribeMode::All, dedup_mode: config::DedupMode::Queue, @@ -3972,6 +4131,7 @@ mod error_outcome_emission_tests { respond_to_allowlist: HashSet::new(), allowed_respond_to: vec![], persona_env_vars: vec![], + has_generated_codex_config: false, relay_observer: false, agent_owner: None, no_base_prompt: false, @@ -3985,7 +4145,7 @@ mod error_outcome_emission_tests { async fn dummy_agent(index: usize) -> OwnedAgent { OwnedAgent { index, - acp: AcpClient::spawn("cat", &[], &[]) + acp: AcpClient::spawn("cat", &[], &[], false) .await .expect("spawn cat as inert agent"), state: Default::default(), @@ -4067,8 +4227,456 @@ mod error_outcome_emission_tests { } #[tokio::test] - async fn timeout_emits_exactly_one_feed_event() { - assert_eq!(turn_errors_emitted_for(PromptOutcome::Timeout).await, 1); + async fn idle_timeout_emits_exactly_one_feed_event() { + assert_eq!( + turn_errors_emitted_for(PromptOutcome::Timeout(TimeoutKind::Idle)).await, + 1 + ); + } + + #[tokio::test] + async fn hard_timeout_emits_exactly_one_feed_event() { + assert_eq!( + turn_errors_emitted_for(PromptOutcome::Timeout(TimeoutKind::Hard)).await, + 1 + ); + } + + #[tokio::test] + async fn cancel_drain_timeout_emits_exactly_one_feed_event() { + assert_eq!( + turn_errors_emitted_for(PromptOutcome::CancelDrainTimeout( + std::time::Duration::from_secs(5) + )) + .await, + 1 + ); + } + + /// idle_timeout outcome_label is "idle_timeout"; hard_timeout is "hard_timeout". + #[tokio::test] + async fn timeout_outcome_labels_differ() { + let check_label = |outcome: PromptOutcome, expected_label: &'static str| async move { + let agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: None, + recoverable_batch: None, + control_tx: None, + steer_tx: None, + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let observer = ObserverHandle::in_process(); + let result = PromptResult { + agent, + source: PromptSource::Channel(Uuid::new_v4()), + outcome, + batch: None, + }; + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + Some(observer.clone()), + None, + ); + let events = observer.snapshot(); + let turn_error = events.iter().find(|e| e.kind == "turn_error").unwrap(); + assert_eq!( + turn_error.payload["outcome"].as_str().unwrap(), + expected_label + ); + }; + check_label(PromptOutcome::Timeout(TimeoutKind::Idle), "idle_timeout").await; + check_label(PromptOutcome::Timeout(TimeoutKind::Hard), "hard_timeout").await; + check_label( + PromptOutcome::CancelDrainTimeout(std::time::Duration::from_secs(5)), + "cancel_drain_timeout", + ) + .await; + } + + /// hard-cap timeout dead-letters immediately (no requeue); idle timeout is requeued. + #[tokio::test] + async fn hard_timeout_not_requeued_idle_timeout_is_requeued() { + let make_batch = || { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "test") + .sign_with_keys(&keys) + .unwrap(); + FlushBatch { + channel_id: Uuid::new_v4(), + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + }; + + // Returns (pending_channels, queued_event_count_for_channel). + let run = |outcome: PromptOutcome, batch: FlushBatch| async move { + let channel_id = batch.channel_id; + let agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: None, + recoverable_batch: None, + control_tx: None, + steer_tx: None, + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + outcome, + batch: Some(batch), + }; + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + ( + queue.pending_channels(), + queue.queued_event_count(&channel_id), + ) + }; + + // Hard timeout: batch must NOT be requeued (dead-lettered immediately). + let hard_batch = make_batch(); + let (hard_channels, hard_events) = + run(PromptOutcome::Timeout(TimeoutKind::Hard), hard_batch).await; + assert_eq!( + hard_channels, 0, + "hard-cap timeout must not requeue the batch" + ); + assert_eq!(hard_events, 0, "hard-cap timeout must drop all events"); + + // Idle timeout: batch IS requeued (first attempt, not yet dead-lettered). + let idle_batch = make_batch(); + let (idle_channels, idle_events) = + run(PromptOutcome::Timeout(TimeoutKind::Idle), idle_batch).await; + assert_eq!( + idle_channels, 1, + "idle timeout must requeue the batch for retry" + ); + assert_eq!( + idle_events, 1, + "idle timeout must preserve the event for retry" + ); + } + + /// Cancel-drain-timeout batches are requeued as cancelled (merge into the + /// next flush, `CancelReason` preserved) — never dead-lettered like a real + /// hard-cap. The agent itself is NOT returned to the idle pool: it is + /// handed to `spawn_respawn_task` instead, mirroring a fatal `Timeout`. + /// + /// This reproduces the full steer-fallback incident, not just the + /// original batch in isolation: the steer ack handler already released + /// the new triggering event back to `queue` (`lib.rs`'s + /// `ExpectedRunIdMissing` path) before the cancel-drain expiry fires. The + /// next `flush_next()` must merge the surviving original event (via + /// `cancelled_events`) with that already-queued new event (via `events`) + /// exactly once each — proving no loss and no duplication. + #[tokio::test] + async fn cancel_drain_timeout_requeues_batch_and_does_not_return_agent() { + let keys = Keys::generate(); + let original_event = EventBuilder::new(Kind::Custom(9), "original") + .sign_with_keys(&keys) + .unwrap(); + let new_event = EventBuilder::new(Kind::Custom(9), "new") + .sign_with_keys(&keys) + .unwrap(); + assert_ne!( + original_event.id, new_event.id, + "test fixture must use two distinct events" + ); + let channel_id = Uuid::new_v4(); + let batch = FlushBatch { + channel_id, + events: vec![BatchEvent { + event: original_event.clone(), + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: Some(CancelReason::Steer), + }; + + let agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: None, + recoverable_batch: None, + control_tx: None, + steer_tx: None, + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + // The steer ack handler releases the new event to the queue BEFORE + // signaling the fallback ControlSignal::Steer that ultimately times + // out on drain — so it is already queued by the time + // handle_prompt_result runs. + queue.push(QueuedEvent { + channel_id, + event: new_event.clone(), + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + }); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let observer = ObserverHandle::in_process(); + let grace = std::time::Duration::from_secs(5); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + outcome: PromptOutcome::CancelDrainTimeout(grace), + batch: Some(batch), + }; + + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + Some(observer.clone()), + None, + ); + + // Batch preserved as a cancelled merge, not dead-lettered — same + // treatment as a normal `Cancelled` outcome. `handle_prompt_result` + // already called `mark_complete` internally, releasing the channel. + // `flush_next()` must merge the already-queued new event with the + // preserved original: each exactly once, in the correct bucket. + let requeued = queue.flush_next().expect("batch must be requeued"); + assert_eq!( + requeued.events.len(), + 1, + "exactly one new event must be in the regular events bucket" + ); + assert_eq!( + requeued.events[0].event.id, new_event.id, + "the regular events bucket must hold the new (already-queued) event" + ); + assert_eq!( + requeued.cancelled_events.len(), + 1, + "exactly one original event must be in the cancelled_events bucket" + ); + assert_eq!( + requeued.cancelled_events[0].event.id, original_event.id, + "the cancelled_events bucket must hold the original (interrupted) event" + ); + assert_ne!( + requeued.events[0].event.id, requeued.cancelled_events[0].event.id, + "the new and original events must not be the same event" + ); + assert_eq!( + requeued.cancel_reason, + Some(CancelReason::Steer), + "CancelReason must ride through to the requeued batch" + ); + + // Agent must NOT be back in the idle pool — it was handed to respawn. + assert_eq!( + pool.live_count(), + 0, + "agent must not be returned to the pool after a cancel-drain timeout" + ); + assert_eq!( + respawn_tasks.len(), + 1, + "a respawn task must be spawned for the poisoned agent" + ); + + // The observer payload must be fate-neutral: it names the grace and + // the process replacement, and must NOT claim the batch was + // preserved — that claim is false for explicit Stop/removed-channel + // drops (see the sibling dropped-Stop test below), so the same + // wording is used regardless of fate. + let events = observer.snapshot(); + let turn_error = events + .iter() + .find(|e| e.kind == "turn_error") + .expect("exactly one turn_error event must be emitted"); + assert_eq!( + turn_error.payload["outcome"].as_str().unwrap(), + "cancel_drain_timeout" + ); + assert_eq!( + turn_error.payload["error"].as_str().unwrap(), + format!("Agent did not stop within {grace:?} after cancellation; the agent process is being replaced."), + "observer message must name the actual grace and must not claim preservation" + ); + assert_eq!( + events.iter().filter(|e| e.kind == "turn_error").count(), + 1, + "exactly one turn_error event must be emitted" + ); + } + + /// Explicit Stop (`ControlSignal::Cancel`) on cancel-drain expiry drops + /// the triggering batch — `requeue_cancelled_batch` returns `None` for + /// `Cancel`/`Rotate`. The observer payload must be the SAME fate-neutral + /// text as the preserved-Steer case above: it must never claim work was + /// preserved when it was intentionally discarded. The poisoned agent is + /// still respawned exactly as in the preserved case. + #[tokio::test] + async fn cancel_drain_timeout_dropped_stop_batch_none_same_neutral_payload() { + let agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: None, + recoverable_batch: None, + control_tx: None, + steer_tx: None, + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let observer = ObserverHandle::in_process(); + let grace = std::time::Duration::from_secs(5); + let result = PromptResult { + agent, + source: PromptSource::Channel(Uuid::new_v4()), + outcome: PromptOutcome::CancelDrainTimeout(grace), + // Explicit Stop already dropped the batch upstream in + // `classify_control_cancel_failure` — `handle_prompt_result` + // never sees one to requeue. + batch: None, + }; + + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + Some(observer.clone()), + None, + ); + + // No batch to merge — the queue has nothing pending for any channel. + assert_eq!( + queue.pending_channels(), + 0, + "a dropped Stop batch must not leave anything queued" + ); + + // Same respawn treatment as the preserved case: never returned idle. + assert_eq!( + pool.live_count(), + 0, + "agent must not be returned to the pool after a cancel-drain timeout" + ); + assert_eq!( + respawn_tasks.len(), + 1, + "a respawn task must be spawned for the poisoned agent" + ); + + // The observer payload is byte-identical to the preserved-Steer case: + // fate-neutral, naming the grace, with no preservation claim. + let events = observer.snapshot(); + let turn_error = events + .iter() + .find(|e| e.kind == "turn_error") + .expect("exactly one turn_error event must be emitted"); + assert_eq!( + turn_error.payload["outcome"].as_str().unwrap(), + "cancel_drain_timeout" + ); + assert_eq!( + turn_error.payload["error"].as_str().unwrap(), + format!("Agent did not stop within {grace:?} after cancellation; the agent process is being replaced."), + "observer message must be fate-neutral even though the batch was dropped" + ); + assert_eq!( + events.iter().filter(|e| e.kind == "turn_error").count(), + 1, + "exactly one turn_error event must be emitted" + ); } #[tokio::test] diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0d3c2fd497..ca0806404c 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -89,6 +89,13 @@ pub struct SessionState { /// channel_id → rendered NIP-AE core prompt section, populated once at /// session creation per Tyler's spec (no mid-session refresh). pub core_sections: HashMap, + /// channel_id → rendered `[Channel Canvas]` metadata section. + /// + /// Populated once before session creation (same lifecycle as `core_sections`). + /// Absent when the channel has no canvas, the canvas content is blank, or the + /// fetch fails — all fail open. Cleared on session invalidation alongside + /// `core_sections` so the next session picks up any canvas change. + pub canvas_sections: HashMap, } impl SessionState { @@ -110,6 +117,7 @@ impl SessionState { pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> bool { self.turn_counts.remove(channel_id); self.core_sections.remove(channel_id); + self.canvas_sections.remove(channel_id); self.sessions.remove(channel_id).is_some() } @@ -120,6 +128,7 @@ impl SessionState { self.heartbeat_session = None; self.heartbeat_turn_count = 0; self.core_sections.clear(); + self.canvas_sections.clear(); } #[cfg(test)] @@ -127,6 +136,7 @@ impl SessionState { self.sessions.contains_key(channel_id) || self.turn_counts.contains_key(channel_id) || self.core_sections.contains_key(channel_id) + || self.canvas_sections.contains_key(channel_id) } } @@ -333,16 +343,35 @@ pub enum SteerAck { PromptCompletedNeutral, } +/// Whether a turn was cut by the idle clock or the hard wall-clock cap. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TimeoutKind { + /// No ACP wire activity for `idle_timeout` seconds. + Idle, + /// Turn ran for `max_turn_duration` seconds of wall-clock time. + Hard, +} + /// Outcome of a prompt task. #[allow(dead_code)] pub enum PromptOutcome { Ok(StopReason), Error(AcpError), AgentExited, - Timeout, + Timeout(TimeoutKind), /// Intentional cancel via `!cancel` command or interrupt mode. /// Agent is healthy — no respawn, no retry penalty. Cancelled, + /// The agent did not stop within `grace` after `session/cancel` was sent + /// for a control-signal cancellation (steer fallback, interrupt, or + /// explicit stop). Distinct from [`TimeoutKind::Hard`]: this is a bounded + /// cleanup deadline, not the turn's configured max-turn wall clock, so it + /// must never be reported or dead-lettered as a hard-cap breach. The + /// agent process is uncertain — treated as poisoned and respawned, same + /// as a hard timeout, but the triggering batch's fate follows the + /// `CancelReason` on the batch (steer/interrupt requeue, explicit cancel + /// drops) rather than the hard-cap's unconditional dead-letter. + CancelDrainTimeout(Duration), } /// Immutable config subset shared (via `Arc`) by all spawned prompt tasks. @@ -360,6 +389,7 @@ pub struct PromptContext { pub turn_liveness_interval: Duration, pub dedup_mode: DedupMode, pub system_prompt: Option, + pub team_instructions: Option, pub heartbeat_prompt: Option, /// Base prompt content, or `None` if `--no-base-prompt` was passed. /// @@ -650,6 +680,13 @@ const CONTEXT_FETCH_RETRY_DELAY: Duration = Duration::from_millis(500); /// Timeout for model-switch requests (`session/set_config_option`, `session/set_model`). const MODEL_SWITCH_TIMEOUT: Duration = Duration::from_secs(5); +/// Bounded grace window for the post-cancel drain after a control-signal +/// cancellation (steer fallback, interrupt, or explicit stop). This is a +/// cleanup deadline, not the turn's configured max-turn wall clock — see +/// [`AcpClient::cancel_with_cleanup_grace`] and +/// [`classify_control_cancel_failure`]. +const CONTROL_CANCEL_GRACE: Duration = Duration::from_secs(5); + /// Timeout for permission-mode requests (`session/set_config_option` with `configId: "mode"`). const PERMISSION_MODE_TIMEOUT: Duration = Duration::from_secs(5); @@ -663,17 +700,25 @@ async fn create_session_and_apply_model( agent: &mut OwnedAgent, ctx: &PromptContext, agent_core: Option<&str>, + agent_canvas: Option<&str>, ) -> Result { - // Combine base_prompt + system_prompt + agent core into a single - // systemPrompt value for the session/new request. Only sent when the agent - // declares protocol version >= 2 (supports systemPrompt); legacy agents + // Combine base_prompt + system_prompt + agent core + canvas metadata into a + // single systemPrompt value for the session/new request. Only sent when the + // agent declares protocol version >= 2 (supports systemPrompt); legacy agents // ignore it and receive the same content as user-message sections via - // `format_prompt`. Core already carries its own `[Agent Memory — core]` - // header from `engram_fetch::build_core_section`, so we just append it. + // `format_prompt`. Core carries its own `[Agent Memory — core]` header, and + // canvas carries its own `[Channel Canvas]` header; both are appended with a + // blank-line separator. let combined_system_prompt: Option = if agent.protocol_version >= 2 { - with_core( - framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), - agent_core, + with_canvas( + with_core( + with_team( + framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), + ctx.team_instructions.as_deref(), + ), + agent_core, + ), + agent_canvas, ) } else { None @@ -927,6 +972,24 @@ pub(crate) fn prepend_base_for_legacy( } } +/// Prepend the `[Channel Canvas]` section to the legacy initial-message body. +/// +/// Protocol-v2 agents already receive the canvas in `systemPrompt`; only +/// legacy (protocol_version < 2) agents need it injected here so it arrives +/// before the first prompt — the same "every turn" semantics as per-turn core. +/// Heartbeats never have an initial_message, so the caller is responsible for +/// not passing a canvas when `source` is `Heartbeat`. +pub(crate) fn prepend_canvas_for_legacy( + protocol_version: u32, + agent_canvas: Option<&str>, + body: &str, +) -> String { + match agent_canvas { + Some(canvas) if protocol_version < 2 => format!("{canvas}\n\n{body}"), + _ => body.to_string(), + } +} + /// Frame the `session/new` `systemPrompt` so each present prompt carries its own /// header, keeping the base/persona boundary recoverable downstream. /// @@ -985,6 +1048,21 @@ fn workspace_section(cwd: &str) -> Option { } } +/// Append the team-owned instruction section after `[System]` and before core memory. +fn with_team(prompt: Option, instructions: Option<&str>) -> Option { + let instructions = instructions + .map(str::trim) + .filter(|value| !value.is_empty()); + match (prompt, instructions) { + (Some(prompt), Some(instructions)) => { + Some(format!("{prompt}\n\n[Team Instructions]\n{instructions}")) + } + (None, Some(instructions)) => Some(format!("[Team Instructions]\n{instructions}")), + (Some(prompt), None) => Some(prompt), + (None, None) => None, + } +} + /// Append the agent's core memory section onto the framed system prompt. /// /// Core already carries its own `[Agent Memory — core]` header from @@ -999,6 +1077,20 @@ fn with_core(framed: Option, core: Option<&str>) -> Option { } } +/// Append the `[Channel Canvas]` metadata section onto the accumulated system prompt. +/// +/// The canvas section already carries its `[Channel Canvas]` header (from +/// `render_canvas_section`), so it is joined with a blank-line separator. +/// Either side may be absent. +fn with_canvas(prompt: Option, canvas: Option<&str>) -> Option { + match (prompt, canvas) { + (Some(prompt), Some(canvas)) => Some(format!("{prompt}\n\n{canvas}")), + (Some(prompt), None) => Some(prompt), + (None, Some(canvas)) => Some(canvas.to_string()), + (None, None) => None, + } +} + /// Return `agent` to the pool via `result_tx`, clearing any steer receiver first. /// /// Every path that returns an `OwnedAgent` to the pool via `PromptResult` goes @@ -1160,6 +1252,39 @@ pub async fn run_prompt_task( } } + // Canvas metadata fetch — same lifecycle as core: once per new channel session, + // never for heartbeats, cached until session invalidation. + // + // DM check: use startup channel_info first; lazy-fetch only when missing. + // A confirmed DM never receives a canvas section. If the channel type cannot + // be determined (metadata absent and lazy fetch fails/unknown), skip the canvas + // rather than assuming non-DM — failing closed on DM ambiguity is safer. + // + // I3 lifecycle: hold the fetched section in a local `pending_canvas` and + // commit it to `canvas_sections` only after session creation succeeds. This + // prevents a stale revision A surviving a failed create and being re-used by + // the next attempt after the canvas was cleared. + let mut pending_canvas: Option<(Uuid, String)> = None; + if let PromptSource::Channel(cid) = &source { + let is_new_channel_session = !agent.state.sessions.contains_key(cid); + if is_new_channel_session && !agent.state.canvas_sections.contains_key(cid) { + // Resolve DM status: prefer the startup cache, lazy-fetch as fallback. + // Unknown → treat as DM (fail-closed). + let is_dm = match ctx.channel_info.get(cid) { + Some(ci) => ci.channel_type == "dm", + None => fetch_channel_info(*cid, &ctx.rest_client) + .await + .map(|ci| ci.channel_type == "dm") + .unwrap_or(true), + }; + if !is_dm { + if let Some(section) = fetch_canvas_section(*cid, &ctx.rest_client).await { + pending_canvas = Some((*cid, section)); + } + } + } + } + // The core section to fold into the system prompt for this turn's session. // Channel-scoped; heartbeats carry no owner core. let agent_core: Option = match &source { @@ -1167,13 +1292,31 @@ pub async fn run_prompt_task( PromptSource::Heartbeat => None, }; + // The canvas metadata section — channel-scoped, absent for heartbeats/DMs. + // Prefer the committed cache; fall back to pending (for new sessions being created now). + let agent_canvas: Option = match &source { + PromptSource::Channel(cid) => agent + .state + .canvas_sections + .get(cid) + .cloned() + .or_else(|| pending_canvas.as_ref().map(|(_, s)| s.clone())), + PromptSource::Heartbeat => None, + }; + let (session_id, is_new_session) = match &source { PromptSource::Channel(cid) => { if let Some(sid) = agent.state.sessions.get(cid) { (sid.clone(), false) } else { // Create new session with model application. - match create_session_and_apply_model(&mut agent, &ctx, agent_core.as_deref()).await + match create_session_and_apply_model( + &mut agent, + &ctx, + agent_core.as_deref(), + agent_canvas.as_deref(), + ) + .await { Ok(sid) => { tracing::info!( @@ -1181,6 +1324,10 @@ pub async fn run_prompt_task( "created session {sid} for channel {cid}" ); agent.state.sessions.insert(*cid, sid.clone()); + // Commit canvas only after session creation succeeds (I3). + if let Some((pending_cid, section)) = pending_canvas.take() { + agent.state.canvas_sections.insert(pending_cid, section); + } (sid, true) } Err(AcpError::AgentExited) => { @@ -1195,6 +1342,8 @@ pub async fn run_prompt_task( return; } Err(e) => { + // Session creation failed; pending canvas was never committed, + // so the next retry will re-fetch a fresh revision. send_prompt_result( &result_tx, agent, @@ -1211,7 +1360,7 @@ pub async fn run_prompt_task( if let Some(sid) = &agent.state.heartbeat_session { (sid.clone(), false) } else { - match create_session_and_apply_model(&mut agent, &ctx, None).await { + match create_session_and_apply_model(&mut agent, &ctx, None, None).await { Ok(sid) => { tracing::info!( target: "pool::session", @@ -1269,8 +1418,16 @@ pub async fn run_prompt_task( // For agents with systemPrompt support (protocol_version >= 2), // base_prompt is delivered via the system role in session/new. // Legacy agents receive it via [Base] in the user message instead. + // Canvas is also injected here for legacy agents: protocol-v2 agents + // already have it in systemPrompt; legacy agents need it before the + // first prompt, matching the "every turn" per-turn delivery semantics. let init_msg = prepend_base_for_legacy(agent.protocol_version, ctx.base_prompt, initial_msg); + let init_msg = prepend_canvas_for_legacy( + agent.protocol_version, + agent_canvas.as_deref(), + &init_msg, + ); let init_result = agent .acp .session_prompt_with_idle_timeout( @@ -1336,7 +1493,7 @@ pub async fn run_prompt_task( &result_tx, agent, source, - PromptOutcome::Timeout, + PromptOutcome::Timeout(TimeoutKind::Idle), requeue_batch_if_queue(&ctx, batch), ); return; @@ -1352,7 +1509,7 @@ pub async fn run_prompt_task( &result_tx, agent, source, - PromptOutcome::Timeout, + PromptOutcome::Timeout(TimeoutKind::Hard), requeue_batch_if_queue(&ctx, batch), ); return; @@ -1431,6 +1588,8 @@ pub async fn run_prompt_task( has_system_prompt_support: agent.protocol_version >= 2, base_prompt: ctx.base_prompt, system_prompt: ctx.system_prompt.as_deref(), + team_instructions: ctx.team_instructions.as_deref(), + agent_canvas: agent_canvas.as_deref(), }, ) } else { @@ -1530,10 +1689,7 @@ pub async fn run_prompt_task( // Prompt is genuinely in-flight — cancel it. match agent .acp - .cancel_with_cleanup_grace( - &session_id, - std::time::Duration::from_secs(5), - ) + .cancel_with_cleanup_grace(&session_id, CONTROL_CANCEL_GRACE) .await { Ok(stop_reason) => { @@ -1561,35 +1717,21 @@ pub async fn run_prompt_task( ); return; } - Err(AcpError::AgentExited) => { - agent.state.invalidate_all(); - let retry_batch = - requeue_cancelled_batch(&ctx, control_signal, batch); - - let usage = agent.acp.take_turn_usage(); - publish_agent_turn_metric( + Err(error) => { + // Single production arm: classify the error→outcome + // and outcome→batch-fate boundary once via the seam + // shared with tests, then invalidate/publish/send once. + let failure = classify_control_cancel_failure( &ctx, - usage, - observer_channel_id, - &session_id, - &turn_id, - Some(buzz_core::agent_turn_metric::StopReason::Error), - ) - .await; - send_prompt_result( - &result_tx, - agent, - source, - PromptOutcome::AgentExited, - retry_batch, + error, + control_signal, + batch, ); - return; - } - Err(AcpError::IdleTimeout(_) | AcpError::HardTimeout) => { - // Cancel drain timed out — agent state uncertain. - agent.state.invalidate(&source); - let retry_batch = - requeue_cancelled_batch(&ctx, control_signal, batch); + if failure.invalidate_all { + agent.state.invalidate_all(); + } else { + agent.state.invalidate(&source); + } let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( @@ -1605,32 +1747,8 @@ pub async fn run_prompt_task( &result_tx, agent, source, - PromptOutcome::Timeout, - retry_batch, - ); - return; - } - Err(e) => { - agent.state.invalidate(&source); - let retry_batch = - requeue_cancelled_batch(&ctx, control_signal, batch); - - let usage = agent.acp.take_turn_usage(); - publish_agent_turn_metric( - &ctx, - usage, - observer_channel_id, - &session_id, - &turn_id, - Some(buzz_core::agent_turn_metric::StopReason::Error), - ) - .await; - send_prompt_result( - &result_tx, - agent, - source, - PromptOutcome::Error(e), - retry_batch, + failure.outcome, + failure.retry_batch, ); return; } @@ -1799,7 +1917,7 @@ pub async fn run_prompt_task( &result_tx, agent, source, - PromptOutcome::Timeout, + PromptOutcome::Timeout(TimeoutKind::Idle), requeue_batch_if_queue(&ctx, batch), ); } @@ -1848,7 +1966,7 @@ pub async fn run_prompt_task( &result_tx, agent, source, - PromptOutcome::Timeout, + PromptOutcome::Timeout(TimeoutKind::Idle), requeue_batch_if_queue(&ctx, batch), ); } @@ -1875,7 +1993,7 @@ pub async fn run_prompt_task( &result_tx, agent, source, - PromptOutcome::Timeout, + PromptOutcome::Timeout(TimeoutKind::Hard), requeue_batch_if_queue(&ctx, batch), ); } @@ -1884,7 +2002,7 @@ pub async fn run_prompt_task( // AgentError means the agent caught a problem before mutating // session state (e.g. bad LLM response). The session is healthy — // don't invalidate it. Other errors may have corrupted state. - if !matches!(e, AcpError::AgentError(_)) { + if !matches!(e, AcpError::AgentError { .. }) { agent.state.invalidate(&source); } let usage = agent.acp.take_turn_usage(); @@ -1997,6 +2115,200 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option Option { + use nostr::{Alphabet, SingleLetterTag}; + + let h_tag = SingleLetterTag::lowercase(Alphabet::H); + let filter = nostr::Filter::new() + .kind(nostr::Kind::Custom(buzz_core::kind::KIND_CANVAS as u16)) + .custom_tags(h_tag, [channel_id.to_string()]) + .limit(1); + + const CANVAS_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); + let json = match tokio::time::timeout( + CANVAS_FETCH_TIMEOUT, + rest.query(std::slice::from_ref(&filter)), + ) + .await + { + Ok(Ok(v)) => v, + Ok(Err(e)) => { + tracing::warn!( + target: "canvas::fetch", + channel = %channel_id, + "canvas query failed: {e} — emitting no section" + ); + return None; + } + Err(_) => { + tracing::warn!( + target: "canvas::fetch", + channel = %channel_id, + timeout_ms = CANVAS_FETCH_TIMEOUT.as_millis() as u64, + "canvas fetch timed out — emitting no section" + ); + return None; + } + }; + + let events = match json.as_array() { + Some(arr) => arr, + None => { + tracing::warn!( + target: "canvas::fetch", + channel = %channel_id, + "canvas query response is not a JSON array — emitting no section" + ); + return None; + } + }; + + canvas_section_from_query_response(events, &channel_id.to_string()) +} + +/// Parse a canvas query response array and render a `[Channel Canvas]` section. +/// +/// Extracted as a pure function so tests can exercise the parsing/validation +/// logic without async machinery or relay connectivity. +/// +/// Returns `None` on: empty array, blank content, malformed/partial event JSON +/// (requires a complete, structurally valid Nostr event), or an out-of-range +/// `created_at` timestamp. Never falls back to epoch or raw integers. +pub(crate) fn canvas_section_from_query_response( + events: &[serde_json::Value], + channel_uuid: &str, +) -> Option { + let raw = events.first()?; + + // Deserialise as a complete Nostr Event. Partial objects (missing pubkey, + // sig, kind, or tags) are rejected here rather than trusted implicitly. + let event = match serde_json::from_value::(raw.clone()) { + Ok(ev) => ev, + Err(err) => { + tracing::warn!( + target: "canvas::fetch", + channel = %channel_uuid, + %err, + "canvas query returned a malformed event — emitting no section", + ); + return None; + } + }; + + // Verify the event's id and signature agree with its content. + // A structurally complete but tampered event must not supply trusted metadata. + if let Err(err) = event.verify() { + tracing::warn!( + target: "canvas::fetch", + channel = %channel_uuid, + %err, + "canvas event failed signature verification — emitting no section", + ); + return None; + } + + // Validate kind: must be KIND_CANVAS (40100). + if event.kind != nostr::Kind::Custom(buzz_core::kind::KIND_CANVAS as u16) { + tracing::warn!( + target: "canvas::fetch", + channel = %channel_uuid, + kind = %event.kind.as_u16(), + "canvas event has unexpected kind — emitting no section", + ); + return None; + } + + // Validate h-tag: must carry the channel UUID we queried. + // The REST boundary filters by #h, but we verify here to prevent a + // misbehaving relay from injecting a different channel's canvas. + let h_tag_matches = event.tags.iter().any(|tag| { + let v = tag.as_slice(); + v.len() >= 2 && v[0] == "h" && v[1] == channel_uuid + }); + if !h_tag_matches { + tracing::warn!( + target: "canvas::fetch", + channel = %channel_uuid, + "canvas event is missing expected h-tag — emitting no section", + ); + return None; + } + + // Blank content means the canvas was cleared; do not fall back to older events. + if event.content.trim().is_empty() { + tracing::debug!( + target: "canvas::fetch", + channel = %channel_uuid, + "latest canvas event has blank content — emitting no section" + ); + return None; + } + + let id = event.id.to_hex(); + + // Convert the Nostr timestamp to a UTC RFC3339 string with Z suffix. + // Use checked conversion: a u64 that exceeds i64::MAX (e.g. Timestamp::max()) + // wraps silently with `as i64`, producing a negative value that chrono would + // accept as a date in 1969. Reject out-of-range values explicitly instead. + let ts_secs = match i64::try_from(event.created_at.as_secs()) { + Ok(s) => s, + Err(_) => { + tracing::warn!( + target: "canvas::fetch", + channel = %channel_uuid, + "canvas event created_at overflows i64 — emitting no section", + ); + return None; + } + }; + let timestamp = match chrono::DateTime::from_timestamp(ts_secs, 0) { + Some(dt) => dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + None => { + tracing::warn!( + target: "canvas::fetch", + channel = %channel_uuid, + ts_secs, + "canvas event has out-of-range created_at — emitting no section", + ); + return None; + } + }; + + tracing::info!( + target: "canvas::fetch", + channel = %channel_uuid, + event_id = %id, + "injected channel canvas metadata section into system prompt" + ); + Some(render_canvas_section(&id, ×tamp, channel_uuid)) +} + +/// Render the `[Channel Canvas]` metadata section string. +/// +/// Pure function — kept separate so unit tests can exercise rendering +/// without async machinery or relay connectivity. +pub(crate) fn render_canvas_section(event_id: &str, timestamp: &str, channel_uuid: &str) -> String { + format!( + "[Channel Canvas]\n\ + Canvas revision (event ID): {event_id}\n\ + Last modified: {timestamp}\n\ + Fetch current content with: buzz canvas get --channel {channel_uuid}" + ) +} + /// Fetch conversation context (thread or DM) for a batch before prompting. /// /// Returns `None` if: @@ -2512,6 +2824,60 @@ fn requeue_cancelled_batch( }) } +/// Result of classifying a failed [`AcpClient::cancel_with_cleanup_grace`] +/// call: the [`PromptOutcome`] to report and the triggering batch's fate, +/// decided together so tests cross the exact error→outcome→batch-fate +/// boundary the production `Err(error)` arm uses. +struct ControlCancelFailure { + outcome: PromptOutcome, + retry_batch: Option, + /// `AgentExited` invalidates every session on the agent; every other + /// failure invalidates only the source that triggered this turn. + invalidate_all: bool, +} + +/// Classify a failed control-signal cancellation (steer fallback, interrupt, +/// or explicit stop) into the [`PromptOutcome`] to report and the triggering +/// batch's fate. This is the single production seam used by the `Err(error)` +/// arm of the control-cancel branch in [`run_prompt_task`] — the boundary +/// this exists to keep singular, so regressions there are regression-tested. +/// +/// [`AcpError::CancelDrainTimeout`] is the expected, common case: the agent +/// didn't stop within its bounded grace window. [`AcpError::HardTimeout`] is +/// not expected here — [`AcpClient::cancel_with_cleanup_grace`] translates its +/// own drain-deadline `HardTimeout` into `CancelDrainTimeout` before +/// returning — but for defense in depth an unexpected `HardTimeout` at this +/// bounded cancellation boundary must never regain real hard-cap/dead-letter +/// classification, so it maps to `CancelDrainTimeout(CONTROL_CANCEL_GRACE)` +/// rather than `Timeout(Hard)`. +fn classify_control_cancel_failure( + ctx: &PromptContext, + error: AcpError, + signal: ControlSignal, + batch: Option, +) -> ControlCancelFailure { + let (outcome, invalidate_all) = match error { + AcpError::AgentExited => (PromptOutcome::AgentExited, true), + AcpError::IdleTimeout(_) => (PromptOutcome::Timeout(TimeoutKind::Idle), false), + AcpError::CancelDrainTimeout(grace) => (PromptOutcome::CancelDrainTimeout(grace), false), + // Defense in depth: this bounded cancellation API is documented to + // translate its own HardTimeout into CancelDrainTimeout, so this arm + // should be unreachable in practice. If it ever fires anyway, still + // report the truthful non-hard outcome rather than the real hard-cap + // (which would dead-letter the batch and claim the configured cap). + AcpError::HardTimeout => ( + PromptOutcome::CancelDrainTimeout(CONTROL_CANCEL_GRACE), + false, + ), + other => (PromptOutcome::Error(other), false), + }; + ControlCancelFailure { + outcome, + retry_batch: requeue_cancelled_batch(ctx, signal, batch), + invalidate_all, + } +} + /// Log a stop reason at the appropriate tracing level. fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { let label = match source { @@ -2743,7 +3109,7 @@ async fn publish_agent_turn_metric( let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); let payload = AgentTurnMetricPayload { harness: ctx.harness_name.clone(), - model: None, + model: usage.model.clone(), channel_id: channel_id.map(|id| id.to_string()), session_id: Some(usage.session_id.clone()), turn_id: Some(turn_id.to_string()), @@ -3031,7 +3397,7 @@ async fn clear_reactions(rest: crate::relay::RestClient, event_ids: Vec) #[cfg(test)] mod tests { use super::*; - use nostr::{EventBuilder, Keys, Kind, Tag}; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; // These pin the initial_message dispatch path (run_prompt_task, ~line 855): @@ -3062,6 +3428,80 @@ mod tests { assert_eq!(composed, "hello channel"); } + // ── prepend_canvas_for_legacy ───────────────────────────────────────────── + + #[test] + fn test_initial_message_legacy_agent_gets_canvas_prepended() { + // Legacy agents (protocol_version < 2) receive the canvas section before + // the initial-message body so it arrives before the first prompt. + let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00Z\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; + let composed = prepend_canvas_for_legacy(1, Some(canvas), "do the thing"); + assert!( + composed.starts_with("[Channel Canvas]"), + "canvas must precede the body" + ); + assert!( + composed.ends_with("do the thing"), + "body must follow the canvas" + ); + assert!( + composed.contains("\n\ndo the thing"), + "canvas and body separated by blank line" + ); + } + + #[test] + fn test_initial_message_modern_agent_omits_canvas_from_body() { + // Protocol-v2 agents receive canvas in systemPrompt; it must NOT be + // duplicated in the initial-message user turn. + let canvas = "[Channel Canvas]\nsome section"; + let composed = prepend_canvas_for_legacy(2, Some(canvas), "do the thing"); + assert_eq!( + composed, "do the thing", + "modern agent initial message must not contain canvas" + ); + assert!( + !composed.contains("[Channel Canvas]"), + "canvas must be absent from modern agent initial message" + ); + } + + #[test] + fn test_initial_message_legacy_agent_no_canvas_is_unchanged() { + // No canvas present: body passes through unmodified. + let composed = prepend_canvas_for_legacy(1, None, "do the thing"); + assert_eq!(composed, "do the thing"); + } + + #[test] + fn test_initial_message_legacy_canvas_and_base_compose_correctly() { + // Verify the full composition order when both base and canvas are present: + // [Base] → canvas section → initial-message body. + let canvas = "[Channel Canvas]\ncanvas content"; + let base_composed = prepend_base_for_legacy(1, Some("be helpful"), "do the thing"); + let full = prepend_canvas_for_legacy(1, Some(canvas), &base_composed); + assert!( + full.starts_with("[Channel Canvas]"), + "canvas must be first in composed message" + ); + assert!( + full.contains("[Base]"), + "base must be present in composed message" + ); + assert!( + full.ends_with("do the thing"), + "body must be last in composed message" + ); + // Order: canvas → base → body + let canvas_pos = full.find("[Channel Canvas]").unwrap(); + let base_pos = full.find("[Base]").unwrap(); + let body_pos = full.find("do the thing").unwrap(); + assert!( + canvas_pos < base_pos && base_pos < body_pos, + "order must be: canvas → base → body" + ); + } + // Pin the session/new systemPrompt framing: each present prompt carries its // own header so the desktop observer can split into labeled sub-sections. @@ -3716,6 +4156,240 @@ mod tests { assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); } + // ── requeue_cancelled_batch ──────────────────────────────────────────── + // Table-driven pin of the `ControlSignal` → `CancelReason` ownership that + // decides whether a cancel-drain-expiry batch is merged into the next + // flush or dropped outright. `Cancel`/`Rotate` must return `None` — a + // regression here would silently fall through to + // `unwrap_or(CancelReason::Steer)` at the requeue site and preserve a + // batch that should have been discarded. + + fn one_event_batch(channel_id: Uuid) -> FlushBatch { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "test") + .sign_with_keys(&keys) + .unwrap(); + FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + #[test] + fn test_requeue_cancelled_batch_maps_control_signal_to_cancel_reason() { + let cases = [ + (ControlSignal::Steer, Some(CancelReason::Steer)), + (ControlSignal::Interrupt, Some(CancelReason::Interrupt)), + ( + ControlSignal::SwitchModel("gpt-5".into()), + Some(CancelReason::Interrupt), + ), + (ControlSignal::Cancel, None), + (ControlSignal::Rotate, None), + ]; + let mut ctx = make_prompt_context_no_owner(); + ctx.dedup_mode = DedupMode::Queue; + + for (signal, expected_reason) in cases { + let channel_id = Uuid::new_v4(); + let batch = one_event_batch(channel_id); + let result = requeue_cancelled_batch(&ctx, signal.clone(), Some(batch)); + match expected_reason { + Some(reason) => { + let batch = result + .unwrap_or_else(|| panic!("{signal:?} must preserve the batch, got None")); + assert_eq!( + batch.cancel_reason, + Some(reason), + "{signal:?} must stamp {reason:?}" + ); + } + None => assert!( + result.is_none(), + "{signal:?} must drop the batch, got {result:?}" + ), + } + } + } + + // ── classify_control_cancel_failure ───────────────────────────────────── + // Table-driven pin of the single production seam used by the + // `Err(error)` arm in `run_prompt_task`'s control-cancel branch. Crosses + // the exact error→outcome AND outcome→batch-fate boundary in one call, + // so a regression to the old per-arm duplication (or to routing an + // unexpected HardTimeout back through the real hard-cap path) fails + // here rather than only in independently-manufactured unit tests. + + /// Assert `outcome` is the expected `PromptOutcome` variant. `PromptOutcome` + /// has no `PartialEq` (it wraps `AcpError`, which isn't `PartialEq`), so + /// this matches by shape instead of deriving equality onto the whole enum. + fn assert_outcome_matches(outcome: &PromptOutcome, expected: &str) { + let label = match outcome { + PromptOutcome::AgentExited => "AgentExited", + PromptOutcome::Timeout(TimeoutKind::Idle) => "Timeout(Idle)", + PromptOutcome::Timeout(TimeoutKind::Hard) => "Timeout(Hard)", + PromptOutcome::CancelDrainTimeout(_) => "CancelDrainTimeout", + PromptOutcome::Error(_) => "Error", + PromptOutcome::Cancelled => "Cancelled", + PromptOutcome::Ok(_) => "Ok", + }; + assert_eq!( + label, expected, + "got outcome shape {label}, want {expected}" + ); + } + + #[test] + fn test_classify_control_cancel_failure_crosses_error_outcome_and_batch_fate() { + let ctx = { + let mut ctx = make_prompt_context_no_owner(); + ctx.dedup_mode = DedupMode::Queue; + ctx + }; + + struct Case { + name: &'static str, + error: fn() -> AcpError, + signal: ControlSignal, + expected_outcome: &'static str, + batch_preserved: bool, + expected_reason: Option, + invalidate_all: bool, + } + + let cases = [ + Case { + name: "CancelDrainTimeout + Steer preserves batch with Steer reason", + error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), + signal: ControlSignal::Steer, + expected_outcome: "CancelDrainTimeout", + batch_preserved: true, + expected_reason: Some(CancelReason::Steer), + invalidate_all: false, + }, + Case { + name: "CancelDrainTimeout + Cancel drops the batch", + error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), + signal: ControlSignal::Cancel, + expected_outcome: "CancelDrainTimeout", + batch_preserved: false, + expected_reason: None, + invalidate_all: false, + }, + Case { + name: "CancelDrainTimeout + Interrupt preserves batch with Interrupt reason", + error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), + signal: ControlSignal::Interrupt, + expected_outcome: "CancelDrainTimeout", + batch_preserved: true, + expected_reason: Some(CancelReason::Interrupt), + invalidate_all: false, + }, + Case { + name: "CancelDrainTimeout + Rotate drops the batch", + error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), + signal: ControlSignal::Rotate, + expected_outcome: "CancelDrainTimeout", + batch_preserved: false, + expected_reason: None, + invalidate_all: false, + }, + Case { + name: "CancelDrainTimeout + SwitchModel preserves batch with Interrupt reason", + error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), + signal: ControlSignal::SwitchModel("gpt-5".to_string()), + expected_outcome: "CancelDrainTimeout", + batch_preserved: true, + expected_reason: Some(CancelReason::Interrupt), + invalidate_all: false, + }, + Case { + name: "unexpected HardTimeout cannot become Timeout(Hard)", + error: || AcpError::HardTimeout, + signal: ControlSignal::Steer, + expected_outcome: "CancelDrainTimeout", + batch_preserved: true, + expected_reason: Some(CancelReason::Steer), + invalidate_all: false, + }, + Case { + name: "AgentExited requests all-session invalidation and preserves via Steer", + error: || AcpError::AgentExited, + signal: ControlSignal::Steer, + expected_outcome: "AgentExited", + batch_preserved: true, + expected_reason: Some(CancelReason::Steer), + invalidate_all: true, + }, + Case { + name: "AgentExited + Cancel still drops the batch", + error: || AcpError::AgentExited, + signal: ControlSignal::Cancel, + expected_outcome: "AgentExited", + batch_preserved: false, + expected_reason: None, + invalidate_all: true, + }, + Case { + name: "IdleTimeout maps to Timeout(Idle)", + error: || AcpError::IdleTimeout(Duration::from_secs(30)), + signal: ControlSignal::Steer, + expected_outcome: "Timeout(Idle)", + batch_preserved: true, + expected_reason: Some(CancelReason::Steer), + invalidate_all: false, + }, + ]; + + for case in cases { + let channel_id = Uuid::new_v4(); + let batch = one_event_batch(channel_id); + let failure = classify_control_cancel_failure( + &ctx, + (case.error)(), + case.signal.clone(), + Some(batch), + ); + assert_outcome_matches(&failure.outcome, case.expected_outcome); + assert_eq!( + failure.invalidate_all, case.invalidate_all, + "{}: invalidate_all mismatch", + case.name + ); + match case.expected_reason { + Some(reason) => { + let batch = failure + .retry_batch + .unwrap_or_else(|| panic!("{}: batch must be preserved", case.name)); + assert_eq!( + batch.cancel_reason, + Some(reason), + "{}: cancel_reason mismatch", + case.name + ); + } + None => assert!( + failure.retry_batch.is_none(), + "{}: batch must be dropped, got {:?}", + case.name, + failure.retry_batch + ), + } + assert_eq!( + case.batch_preserved, + case.expected_reason.is_some(), + "{}: test table internally inconsistent", + case.name + ); + } + } + // ── turn liveness emission ─────────────────────────────────────────────── // `run_turn_liveness` is raced against a "prompt" future the same way // `run_prompt_task` does it: the prompt wins the select and the liveness @@ -3818,9 +4492,14 @@ mod tests { /// `install_steer_rx` does not panic. #[tokio::test] async fn test_send_prompt_result_clears_steer_rx_on_early_return() { - let acp = AcpClient::spawn("bash", &["-c".to_string(), "sleep 10".to_string()], &[]) - .await - .expect("failed to spawn test agent"); + let acp = AcpClient::spawn( + "bash", + &["-c".to_string(), "sleep 10".to_string()], + &[], + false, + ) + .await + .expect("failed to spawn test agent"); let mut agent = OwnedAgent { index: 0, acp, @@ -3868,9 +4547,14 @@ mod tests { /// and the next `install_steer_rx` does not panic. #[tokio::test] async fn test_send_prompt_result_is_noop_when_steer_rx_already_consumed() { - let acp = AcpClient::spawn("bash", &["-c".to_string(), "sleep 10".to_string()], &[]) - .await - .expect("failed to spawn test agent"); + let acp = AcpClient::spawn( + "bash", + &["-c".to_string(), "sleep 10".to_string()], + &[], + false, + ) + .await + .expect("failed to spawn test agent"); let agent = OwnedAgent { index: 0, acp, @@ -3965,6 +4649,7 @@ mod tests { cumulative_input_tokens: 100, cumulative_output_tokens: 50, cumulative_cost_usd: None, + model: None, }; // owner_pubkey = None → early return, no panic. publish_agent_turn_metric( @@ -3996,6 +4681,7 @@ mod tests { cumulative_input_tokens: 200, cumulative_output_tokens: 80, cumulative_cost_usd: Some(0.001), + model: None, }; // Will try to publish and fail (no real relay) but must not panic. publish_agent_turn_metric( @@ -4028,6 +4714,7 @@ mod tests { cumulative_input_tokens: 150, cumulative_output_tokens: 70, cumulative_cost_usd: None, + model: None, }; // Must not panic; HTTP submit will fail (no real relay) — that's fine. publish_agent_turn_metric( @@ -4060,6 +4747,7 @@ mod tests { cumulative_input_tokens: 400, cumulative_output_tokens: 100, cumulative_cost_usd: None, + model: None, }; // Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic. publish_agent_turn_metric( @@ -4098,6 +4786,7 @@ mod tests { turn_liveness_interval: Duration::ZERO, dedup_mode: DedupMode::Drop, system_prompt: None, + team_instructions: None, heartbeat_prompt: None, base_prompt: None, cwd: ".".to_string(), @@ -4117,4 +4806,312 @@ mod tests { harness_name: "goose".to_string(), } } + + // ── render_canvas_section ──────────────────────────────────────────────── + + #[test] + fn test_render_canvas_section_produces_exact_shape() { + let id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let ts = "2024-01-15T10:30:00+00:00"; + let uuid = "00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; + let section = render_canvas_section(id, ts, uuid); + assert_eq!( + section, + "[Channel Canvas]\n\ + Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n\ + Last modified: 2024-01-15T10:30:00+00:00\n\ + Fetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae" + ); + } + + // ── with_canvas ────────────────────────────────────────────────────────── + + #[test] + fn test_with_canvas_appends_to_existing_prompt() { + let result = with_canvas(Some("base content".into()), Some("[Channel Canvas]\nstuff")); + assert_eq!(result.unwrap(), "base content\n\n[Channel Canvas]\nstuff"); + } + + #[test] + fn test_with_canvas_returns_canvas_alone_when_no_prompt() { + let result = with_canvas(None, Some("[Channel Canvas]\nstuff")); + assert_eq!(result.unwrap(), "[Channel Canvas]\nstuff"); + } + + #[test] + fn test_with_canvas_returns_prompt_alone_when_no_canvas() { + let result = with_canvas(Some("base content".into()), None); + assert_eq!(result.unwrap(), "base content"); + } + + #[test] + fn test_with_canvas_returns_none_when_both_absent() { + let result = with_canvas(None, None); + assert!(result.is_none()); + } + + // ── canvas_sections cache invalidation ─────────────────────────────────── + + #[test] + fn test_invalidate_channel_clears_canvas_section() { + let ch = Uuid::new_v4(); + let mut s = SessionState::default(); + s.sessions.insert(ch, "sess".into()); + s.canvas_sections + .insert(ch, "[Channel Canvas]\nrev abc".into()); + + s.invalidate_channel(&ch); + + assert!(!s.canvas_sections.contains_key(&ch)); + assert!(!s.sessions.contains_key(&ch)); + } + + #[test] + fn test_invalidate_all_clears_canvas_sections() { + let ch_a = Uuid::new_v4(); + let ch_b = Uuid::new_v4(); + let mut s = SessionState::default(); + s.canvas_sections.insert(ch_a, "canvas-a".into()); + s.canvas_sections.insert(ch_b, "canvas-b".into()); + s.sessions.insert(ch_a, "sess-a".into()); + + s.invalidate_all(); + + assert!(s.canvas_sections.is_empty()); + assert!(s.sessions.is_empty()); + } + + #[test] + fn test_invalidate_channel_leaves_other_channels_canvas_intact() { + let ch_a = Uuid::new_v4(); + let ch_b = Uuid::new_v4(); + let mut s = SessionState::default(); + s.sessions.insert(ch_a, "sess-a".into()); + s.sessions.insert(ch_b, "sess-b".into()); + s.canvas_sections.insert(ch_a, "canvas-a".into()); + s.canvas_sections.insert(ch_b, "canvas-b".into()); + + s.invalidate_channel(&ch_a); + + assert!(!s.canvas_sections.contains_key(&ch_a)); + assert_eq!(s.canvas_sections.get(&ch_b).unwrap(), "canvas-b"); + } + + #[test] + fn test_has_channel_state_true_when_only_canvas_section_present() { + let ch = Uuid::new_v4(); + let mut s = SessionState::default(); + s.canvas_sections.insert(ch, "canvas".into()); + assert!(s.has_channel_state(&ch)); + } + + // ── canvas_section_from_query_response ─────────────────────────────────── + + const CHANNEL_UUID: &str = "00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; + + /// Build a real, cryptographically signed Nostr canvas event for tests. + /// + /// Includes the correct kind (40100) and an `h` tag carrying `CHANNEL_UUID` + /// so all structural and content validations pass. + fn make_canvas_event_value(content: &str) -> serde_json::Value { + let keys = Keys::generate(); + let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag"); + let event = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), content) + .tags([h_tag]) + .sign_with_keys(&keys) + .expect("sign"); + serde_json::to_value(&event).expect("serialise") + } + + #[test] + fn test_canvas_section_from_query_response_happy_path() { + let ev = make_canvas_event_value("# Team instructions\nBe helpful."); + let id = ev["id"].as_str().unwrap().to_string(); + let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID); + let section = result.expect("expected Some"); + assert!(section.contains(&id), "section must contain the event id"); + assert!(section.contains("buzz canvas get --channel")); + assert!(section.contains(CHANNEL_UUID)); + assert!(section.starts_with("[Channel Canvas]")); + // Timestamp must use Z suffix, not +00:00 + assert!(section.contains('Z'), "timestamp must use Z suffix"); + } + + #[test] + fn test_canvas_section_from_query_response_empty_array_returns_none() { + let result = canvas_section_from_query_response(&[], CHANNEL_UUID); + assert!(result.is_none()); + } + + #[test] + fn test_canvas_section_from_query_response_blank_content_returns_none() { + let ev = make_canvas_event_value(" "); + let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID); + assert!( + result.is_none(), + "blank content must return None (cleared canvas)" + ); + } + + #[test] + fn test_canvas_section_from_query_response_empty_content_returns_none() { + let ev = make_canvas_event_value(""); + let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID); + assert!(result.is_none()); + } + + /// A bare JSON object with a plausible-looking id but missing pubkey/sig/kind/tags + /// must be rejected — not silently accepted with partial metadata. + #[test] + fn test_canvas_section_from_query_response_partial_object_returns_none() { + let partial = serde_json::json!({ + "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "created_at": 1705312200_i64, + "content": "some instructions" + }); + let result = canvas_section_from_query_response(&[partial], CHANNEL_UUID); + assert!( + result.is_none(), + "partial event object (missing pubkey/sig/kind/tags) must return None" + ); + } + + /// A JSON object that looks like an event but has `created_at` as a string + /// must be rejected — the nostr::Event parser enforces integer type. + #[test] + fn test_canvas_section_from_query_response_string_timestamp_returns_none() { + let keys = Keys::generate(); + let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag"); + let mut ev = serde_json::to_value( + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "content") + .tags([h_tag]) + .sign_with_keys(&keys) + .expect("sign"), + ) + .expect("serialise"); + // Corrupt created_at to a string value. + ev["created_at"] = serde_json::Value::String("2026-03-15T16:30:00+00:00".into()); + let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID); + assert!( + result.is_none(), + "string created_at must be rejected by nostr::Event deserialiser" + ); + } + + /// A JSON object that looks like an event but is missing `created_at` + /// must be rejected — nostr::Event requires the field. + #[test] + fn test_canvas_section_from_query_response_missing_timestamp_returns_none() { + let keys = Keys::generate(); + let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag"); + let mut ev = serde_json::to_value( + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "content") + .tags([h_tag]) + .sign_with_keys(&keys) + .expect("sign"), + ) + .expect("serialise"); + ev.as_object_mut().unwrap().remove("created_at"); + let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID); + assert!( + result.is_none(), + "missing created_at must be rejected by nostr::Event deserialiser" + ); + } + + /// An event with a timestamp at Timestamp::max() (u64::MAX) must return None. + /// + /// `u64::MAX as i64` wraps to -1, which chrono silently accepts as + /// 1969-12-31T23:59:59Z. The checked i64::try_from must reject it first. + #[test] + fn test_canvas_section_from_query_response_timestamp_max_returns_none() { + let keys = Keys::generate(); + let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag"); + let ev = serde_json::to_value( + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "content") + .tags([h_tag]) + .custom_created_at(Timestamp::max()) + .sign_with_keys(&keys) + .expect("sign"), + ) + .expect("serialise"); + let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID); + assert!( + result.is_none(), + "Timestamp::max() (u64::MAX) must return None — not wrap to 1969" + ); + } + + /// A structurally complete but tampered event (content altered after signing) + /// must be rejected by event.verify(). + #[test] + fn test_canvas_section_from_query_response_tampered_event_returns_none() { + let keys = Keys::generate(); + let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag"); + let mut ev = serde_json::to_value( + EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), + "original", + ) + .tags([h_tag]) + .sign_with_keys(&keys) + .expect("sign"), + ) + .expect("serialise"); + // Tamper the content after signing — id and sig no longer agree. + ev["content"] = serde_json::Value::String("injected instructions".into()); + let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID); + assert!( + result.is_none(), + "tampered event must fail verify() and return None" + ); + } + + /// An event with the wrong kind (not 40100) must be rejected. + #[test] + fn test_canvas_section_from_query_response_wrong_kind_returns_none() { + let keys = Keys::generate(); + let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag"); + let ev = serde_json::to_value( + EventBuilder::new(Kind::Custom(9), "content") + .tags([h_tag]) + .sign_with_keys(&keys) + .expect("sign"), + ) + .expect("serialise"); + let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID); + assert!(result.is_none(), "wrong kind must return None"); + } + + /// An event missing the expected h-tag (or carrying a different channel UUID) + /// must be rejected. + #[test] + fn test_canvas_section_from_query_response_wrong_h_tag_returns_none() { + let keys = Keys::generate(); + let wrong_h = Tag::parse(["h", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]).expect("h tag"); + let ev = serde_json::to_value( + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "content") + .tags([wrong_h]) + .sign_with_keys(&keys) + .expect("sign"), + ) + .expect("serialise"); + let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID); + assert!(result.is_none(), "mismatched h-tag must return None"); + } + + #[test] + fn test_canvas_section_from_query_response_timestamp_uses_z_suffix() { + let ev = make_canvas_event_value("instructions"); + let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID); + let section = result.expect("valid event must produce a section"); + assert!( + section.contains('Z'), + "RFC3339 timestamp must use Z suffix, not +00:00" + ); + assert!( + !section.contains("+00:00"), + "timestamp must not use +00:00 offset" + ); + } } diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 5a82ad6845..dc4e5bffb0 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -35,8 +35,11 @@ const BASE_RETRY_DELAY_SECS: u64 = 5; /// Cap on retry delay in seconds. const MAX_RETRY_DELAY_SECS: u64 = 300; -/// In-flight deadline: max_turn (3600s) + 100s buffer. -const IN_FLIGHT_DEADLINE_SECS: u64 = 3700; +/// Buffer added to `max_turn_duration` to derive the in-flight deadline. +const IN_FLIGHT_DEADLINE_BUFFER_SECS: u64 = 100; + +/// Default in-flight deadline: default max_turn (7200s) + 100s buffer. +const DEFAULT_IN_FLIGHT_DEADLINE_SECS: u64 = 7300; /// An event waiting in the queue. #[derive(Debug, Clone)] @@ -94,7 +97,7 @@ pub struct FlushBatch { /// State: /// queues: Map> (capped at MAX_PENDING_PER_CHANNEL) /// in_flight_channels: HashSet -/// in_flight_deadlines: Map (auto-expire after IN_FLIGHT_DEADLINE_SECS) +/// in_flight_deadlines: Map (auto-expire after in_flight_deadline) /// retry_after: Map /// retry_counts: Map (dead-letter after MAX_RETRIES) /// dedup_mode: DedupMode @@ -117,7 +120,7 @@ pub struct FlushBatch { /// channel = pick candidate with oldest head event (min received_at) /// events = drain up to MAX_BATCH_EVENTS from queues[channel] /// in_flight_channels.insert(channel) -/// in_flight_deadlines.insert(channel, now + IN_FLIGHT_DEADLINE_SECS) +/// in_flight_deadlines.insert(channel, now + in_flight_deadline) /// return Some(FlushBatch { channel, events }) /// /// mark_complete(channel_id): @@ -157,14 +160,22 @@ pub struct EventQueue { /// path. Populated by [`mark_native_steer_pending`]; drained back to the /// queue front by [`release_native_steer`] (preserving original /// `received_at` fairness, same discipline as `requeue_preserve_timestamps` - /// at line 453). Bulk recovery on `IN_FLIGHT_DEADLINE_SECS` expiry is - /// performed by `flush_next` / `has_flushable_work` (recover, not - /// log-and-drop — the events were never delivered to the agent). + /// at line 453). Bulk recovery on in-flight deadline expiry is performed + /// by `flush_next` / `has_flushable_work` (recover, not log-and-drop — + /// the events were never delivered to the agent). withheld_native_steer: HashMap>, + /// Duration after which an in-flight channel is auto-expired as orphaned. + /// Must be strictly greater than `max_turn_duration` so a turn running to + /// the hard cap returns via `mark_complete` before the backstop fires. + in_flight_deadline: Duration, } impl EventQueue { /// Create a new empty event queue with the given dedup mode. + /// + /// Uses [`DEFAULT_IN_FLIGHT_DEADLINE_SECS`] for the in-flight backstop. + /// Call [`with_in_flight_deadline`](Self::with_in_flight_deadline) to + /// derive the deadline from the configured `max_turn_duration`. pub fn new(dedup_mode: DedupMode) -> Self { Self { queues: HashMap::new(), @@ -177,9 +188,18 @@ impl EventQueue { cancelled_batches: HashMap::new(), cancel_reasons: HashMap::new(), withheld_native_steer: HashMap::new(), + in_flight_deadline: Duration::from_secs(DEFAULT_IN_FLIGHT_DEADLINE_SECS), } } + /// Set the in-flight backstop deadline from the configured max turn + /// duration, preserving the 100s buffer for cancel-drain grace + respawn. + pub fn with_in_flight_deadline(mut self, max_turn_duration_secs: u64) -> Self { + self.in_flight_deadline = + Duration::from_secs(max_turn_duration_secs + IN_FLIGHT_DEADLINE_BUFFER_SECS); + self + } + /// Push an event into the queue for its channel. /// /// In [`DedupMode::Drop`], events for any currently in-flight channel are @@ -231,7 +251,7 @@ impl EventQueue { tracing::error!( channel_id = %id, lost_events, - deadline_secs = IN_FLIGHT_DEADLINE_SECS, + deadline_secs = self.in_flight_deadline.as_secs(), "BUG: in-flight channel expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); @@ -277,7 +297,7 @@ impl EventQueue { let cancel_reason = self.cancel_reasons.remove(&id); self.in_flight_channels.insert(id); self.in_flight_deadlines - .insert(id, now + Duration::from_secs(IN_FLIGHT_DEADLINE_SECS)); + .insert(id, now + self.in_flight_deadline); self.in_flight_batch_sizes.insert(id, cancelled.len()); return Some(FlushBatch { channel_id: id, @@ -294,7 +314,7 @@ impl EventQueue { // Drain up to MAX_BATCH_EVENTS; leave any remainder in the queue. let queue = self.queues.entry(channel_id).or_default(); let drain_count = MAX_BATCH_EVENTS.min(queue.len()); - let events: Vec = queue + let mut events: Vec = queue .drain(..drain_count) .map(|qe| BatchEvent { event: qe.event, @@ -302,6 +322,11 @@ impl EventQueue { received_at: qe.received_at, }) .collect(); + // Relay replay delivers stored events newest-first (`ORDER BY + // created_at DESC`), but batch consumers — `format_prompt` scope and + // reply-anchor selection — require the LAST event to be the newest. + // Stable sort: same-second events keep delivery order. + events.sort_by_key(|be| be.event.created_at); // Remove the queue entry if now empty. if self.queues.get(&channel_id).is_some_and(|q| q.is_empty()) { @@ -309,10 +334,8 @@ impl EventQueue { } self.in_flight_channels.insert(channel_id); - self.in_flight_deadlines.insert( - channel_id, - now + Duration::from_secs(IN_FLIGHT_DEADLINE_SECS), - ); + self.in_flight_deadlines + .insert(channel_id, now + self.in_flight_deadline); self.in_flight_batch_sizes.insert(channel_id, events.len()); // Merge any cancelled events stored by requeue_as_cancelled(). @@ -524,7 +547,7 @@ impl EventQueue { tracing::error!( channel_id = %id, lost_events, - deadline_secs = IN_FLIGHT_DEADLINE_SECS, + deadline_secs = self.in_flight_deadline.as_secs(), "BUG: in-flight channel expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); @@ -551,6 +574,12 @@ impl EventQueue { self.queues.len() } + /// Number of queued events for a specific channel. Test-only. + #[cfg(test)] + pub fn queued_event_count(&self, channel_id: &Uuid) -> usize { + self.queues.get(channel_id).map_or(0, |q| q.len()) + } + /// Drop all queued (non-in-flight) events for a channel. /// /// Used when the agent is removed from a channel — any pending events @@ -693,7 +722,7 @@ impl EventQueue { /// Bulk-release every withheld event for `channel_id` back to the queue /// front, preserving relative FIFO order. /// - /// Called from the `IN_FLIGHT_DEADLINE_SECS` expiry blocks in + /// Called from the `in_flight_deadline` expiry blocks in /// `flush_next` and `has_flushable_work` — if a steer ack never arrives /// (read loop hung, watcher never posted), the withheld events would /// otherwise be permanently orphaned. Recover, do not log-and-drop: the @@ -1303,6 +1332,15 @@ pub struct FormatPromptArgs<'a> { pub base_prompt: Option<&'a str>, /// System prompt content for legacy agents (protocol_version < 2). pub system_prompt: Option<&'a str>, + /// Team instructions for legacy agents, rendered after `[System]`. + pub team_instructions: Option<&'a str>, + /// Rendered `[Channel Canvas]` metadata section for legacy agents. + /// + /// For modern agents (protocol_version >= 2) the section is delivered via + /// the system role in session/new; omit here to avoid duplication. + /// For legacy agents it rides in the user message on every turn of the + /// session, alongside `[Base]`/`[System]`/`[Agent Memory — core]`. + pub agent_canvas: Option<&'a str>, } /// Format the `[Base]` section for the base prompt. @@ -1364,6 +1402,13 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec) -> Vec (&'static str, &'static str) { #[cfg(test)] mod tests { use super::*; - use nostr::{EventBuilder, Keys, Kind}; + use nostr::{EventBuilder, Keys, Kind, Timestamp}; use std::time::Duration; /// Build a test event with the given content and kind. @@ -1580,6 +1629,26 @@ mod tests { } } + /// Build a QueuedEvent with an explicit Nostr `created_at` timestamp. + fn make_queued_created_at( + channel_id: Uuid, + content: &str, + created_at_secs: u64, + ) -> QueuedEvent { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), content) + .custom_created_at(Timestamp::from(created_at_secs)) + .tags([]) + .sign_with_keys(&keys) + .unwrap(); + QueuedEvent { + channel_id, + event, + received_at: Instant::now(), + prompt_tag: "test".into(), + } + } + fn pending_count(q: &EventQueue) -> usize { q.queues.values().map(|q| q.len()).sum() } @@ -1678,6 +1747,34 @@ mod tests { assert_eq!(q.queues.len(), 0); } + #[test] + fn test_flush_orders_replayed_events_chronologically() { + // Relay replay after a reconnect/restart delivers stored events newest + // first (`ORDER BY created_at DESC`), but `format_prompt` derives the + // reply anchor and scope from the LAST batch event on the assumption + // that it is the newest. The drained batch must therefore be + // chronological regardless of delivery order. + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + + q.push(make_queued_created_at(ch, "newest", 2_000)); + q.push(make_queued_created_at(ch, "oldest", 1_000)); + + let batch = q.flush_next().expect("should return batch"); + assert_eq!(batch.events.len(), 2); + assert_eq!(batch.events[0].event.content, "oldest"); + assert_eq!(batch.events[1].event.content, "newest"); + + // The rendered prompt's reply anchor must cite the newest event, so + // the agent's reply threads under the message it is responding to. + let newest_id = batch.events[1].event.id.to_hex(); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); + assert!( + prompt.contains(&format!("--reply-to {newest_id}")), + "reply anchor must target the newest event; prompt was:\n{prompt}" + ); + } + #[test] fn test_fifo_fairness_picks_oldest_channel() { let mut q = EventQueue::new(DedupMode::Queue); @@ -4139,7 +4236,7 @@ mod tests { // `flush_next` / `has_flushable_work` / contiguous drain. `Success` ack // drops it via `remove_event`; `Err` / `PromptCompletedNeutral` ack // restores it to the queue front via `release_native_steer`. The - // `IN_FLIGHT_DEADLINE_SECS` expiry bulk-recovers withheld events so they + // `in_flight_deadline` expiry bulk-recovers withheld events so they // are never permanently orphaned. /// A channel whose only queued event has been withheld for a goose-native @@ -4219,7 +4316,7 @@ mod tests { } /// If the steer ack never arrives — read loop hung, watcher never posted — - /// the `IN_FLIGHT_DEADLINE_SECS` auto-expiry block must bulk-recover the + /// the `in_flight_deadline` auto-expiry block must bulk-recover the /// withheld events back to the queue front so normal dispatch can deliver /// them. Recover, not log-and-drop: the events were never seen by the /// agent. @@ -4241,7 +4338,7 @@ mod tests { // Force the in-flight deadline to be in the past, simulating the // steer ack never arriving and the read loop hanging long enough - // for `IN_FLIGHT_DEADLINE_SECS` to elapse. Same expiry-simulation + // for `in_flight_deadline` to elapse. Same expiry-simulation // trick used by `test_retry_throttle_blocks_requeue_channel`. q.in_flight_deadlines .insert(ch, Instant::now() - Duration::from_secs(1)); @@ -4317,4 +4414,112 @@ mod tests { assert_eq!(recovered, vec![e1_id, e2_id, e3_id]); assert!(q.withheld_native_steer.is_empty()); } + + // ── format_prompt: agent_canvas ───────────────────────────────────────── + + #[test] + fn test_format_prompt_canvas_injected_for_legacy_agent() { + let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00+00:00\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; + let ch = Uuid::new_v4(); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event: make_event("hi"), + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + agent_canvas: Some(canvas), + has_system_prompt_support: false, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + prompt.contains("[Channel Canvas]"), + "legacy agent prompt must include canvas section; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_canvas_omitted_for_modern_agent() { + let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00+00:00\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; + let ch = Uuid::new_v4(); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event: make_event("hi"), + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + agent_canvas: Some(canvas), + has_system_prompt_support: true, + ..Default::default() + }, + ) + .join("\n\n"); + assert!( + !prompt.contains("[Channel Canvas]"), + "modern agent must not get canvas in user message (it's in systemPrompt); got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_no_canvas_produces_no_canvas_section() { + let ch = Uuid::new_v4(); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event: make_event("hi"), + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); + assert!( + !prompt.contains("[Channel Canvas]"), + "no canvas section expected when agent_canvas is None; got: {prompt}" + ); + } + + #[test] + fn default_in_flight_deadline_exceeds_default_max_turn_duration() { + let q = EventQueue::new(DedupMode::Queue); + let default_max_turn = Duration::from_secs(crate::config::DEFAULT_MAX_TURN_DURATION_SECS); + assert!( + q.in_flight_deadline > default_max_turn, + "in_flight_deadline ({:?}) must be strictly greater than \ + default max_turn_duration ({:?})", + q.in_flight_deadline, + default_max_turn, + ); + } + + #[test] + fn with_in_flight_deadline_derives_from_max_turn_duration() { + let max_turn = 9000u64; + let q = EventQueue::new(DedupMode::Queue).with_in_flight_deadline(max_turn); + let expected = Duration::from_secs(max_turn + IN_FLIGHT_DEADLINE_BUFFER_SECS); + assert_eq!( + q.in_flight_deadline, expected, + "in_flight_deadline should be max_turn_duration + buffer" + ); + assert!( + q.in_flight_deadline > Duration::from_secs(max_turn), + "in_flight_deadline must be strictly greater than max_turn_duration" + ); + } } diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs new file mode 100644 index 0000000000..16b89fce7e --- /dev/null +++ b/crates/buzz-acp/src/setup_mode.rs @@ -0,0 +1,1080 @@ +//! Setup-mode listener for not-ready agents. +//! +//! When the desktop determines an agent is `NotReady` (missing provider, +//! model, or credentials), it spawns `buzz-acp` with a setup-mode payload +//! instead of starting the normal agent pool. This module implements that +//! early-branch path: +//! +//! ```text +//! Config::from_cli() +//! └─ SetupPayload::from_env()? +//! ├─ Some(payload) → run_setup_listener(config, payload) [this module] +//! └─ None → normal pool path (unchanged) +//! ``` +//! +//! # Contract (NON-NEGOTIABLE) +//! +//! * **Desktop is the ONLY readiness source.** `buzz-acp` trusts the payload +//! passed by the desktop and does NOT re-derive readiness. +//! * **Normal startup gains no second readiness path.** The early branch is +//! entered only when `BUZZ_ACP_SETUP_PAYLOAD` is set. +//! * `spawn_key_refusal`-class identity failures are outside this path: no +//! valid key → no safe process to post as the agent. +//! +//! # Nudge mechanics +//! +//! Connect → subscribe → on each matching event: +//! 1. Apply `ignore_self` gate. +//! 2. Apply `author_allowed` gate (same as normal mode) so the nudge goes +//! only to authors the real agent would answer. +//! 3. Require an explicit @mention via `event_mentions_agent`. +//! 4. Apply `filter::match_event` so channel/kind rules still constrain. +//! 5. Build and publish a nudge reply (surface-correct copy). +//! 6. Deduplicate by event-id (reconnect replay must not double-nudge). + +use std::collections::HashSet; + +use anyhow::Result; +use buzz_core::kind::{ + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, + KIND_WORKFLOW_APPROVAL_REQUESTED, +}; +use nostr::EventId; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ── Availability mirror ──────────────────────────────────────────────────────── + +/// Granular install/auth state for a CLI-backed ACP harness. +/// +/// Mirrors the desktop `AcpAvailabilityStatus` enum (and the FE +/// `AcpAvailabilityStatus` type in `api/types.ts`). Carried on +/// `RequirementPayload::CliLogin` so the sentinel JSON the desktop parses +/// contains the exact wire literals the FE expects. +/// +/// buzz-acp is a separate crate and must NOT depend on desktop types — +/// this explicit mirror is the correct pattern (same as the rest of +/// `RequirementPayload` as "the Rust counterpart to desktop's `Requirement`"). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AcpAvailabilityStatus { + /// Adapter + CLI both present; may still need login. + Available, + /// ACP adapter binary missing; underlying CLI may be present. + AdapterMissing, + /// ACP adapter binary is from the deprecated package (< 1.0). Reinstall required. + AdapterOutdated, + /// CLI binary missing; ACP adapter may be present. + CliMissing, + /// Neither adapter nor CLI found. + NotInstalled, +} + +use crate::{ + author_allowed, + config::Config, + event_mentions_agent, filter, + relay::{HarnessRelay, RelayEventPublisher}, +}; + +// ── Payload ─────────────────────────────────────────────────────────────────── + +/// Env var carrying the JSON-encoded setup payload. +pub(crate) const SETUP_PAYLOAD_ENV_VAR: &str = "BUZZ_ACP_SETUP_PAYLOAD"; + +/// A single missing requirement, surface-discriminated so the nudge copy +/// names exactly what to set and where. +/// +/// This is the Rust counterpart to the desktop's `Requirement` type +/// (`managed_agents/readiness.rs`). Desktop serializes it; `buzz-acp` +/// deserializes and renders copy from it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "surface", rename_all = "snake_case")] +pub(crate) enum RequirementPayload { + /// A normalized dropdown field (provider or model) missing. + NormalizedField { field: String }, + /// An env-backed credential that is absent. + EnvKey { key: String }, + /// A CLI authentication step that must be completed interactively. + CliLogin { + probe_args: Vec, + setup_copy: String, + /// Granular install/auth state — determines copy and CTA routing on + /// the desktop card. `Available` means tooling is present but login + /// is needed; the other three variants mean the tooling itself is + /// missing and the probe was skipped. + availability: AcpAvailabilityStatus, + }, + /// The CLI is installed but its config file could not be parsed. + /// Informational only — no in-app action can fix an external config file. + CliConfigInvalid { + probe_args: Vec, + setup_copy: String, + /// One-line stderr excerpt identifying the parse error. + diagnostic: String, + }, + /// Git for Windows is missing; open Doctor for the installation guide. + GitBash, +} + +impl RequirementPayload { + /// Human-readable instruction fragment for the nudge copy. + fn instruction(&self) -> String { + match self { + RequirementPayload::NormalizedField { field } => { + format!("set the **{}** field in Edit Agent dropdowns", field) + } + RequirementPayload::EnvKey { key } => { + format!("set `{}` in Edit Agent → Environment variables", key) + } + RequirementPayload::CliLogin { + setup_copy, + availability, + probe_args, + } => match availability { + AcpAvailabilityStatus::Available => setup_copy.clone(), + AcpAvailabilityStatus::AdapterMissing => { + let harness = probe_args + .first() + .map(String::as_str) + .unwrap_or("the agent"); + format!( + "install the {} ACP adapter (open Doctor in Settings to diagnose)", + harness + ) + } + AcpAvailabilityStatus::AdapterOutdated => { + let harness = probe_args + .first() + .map(String::as_str) + .unwrap_or("the agent"); + format!( + "reinstall the {} ACP adapter — the installed version is outdated (open Doctor in Settings to diagnose)", + harness + ) + } + AcpAvailabilityStatus::CliMissing => { + let harness = probe_args + .first() + .map(String::as_str) + .unwrap_or("the agent"); + format!( + "install {} CLI (open Doctor in Settings to diagnose)", + harness + ) + } + AcpAvailabilityStatus::NotInstalled => { + let harness = probe_args + .first() + .map(String::as_str) + .unwrap_or("the agent"); + format!("install {} (open Doctor in Settings to diagnose)", harness) + } + }, + RequirementPayload::CliConfigInvalid { + probe_args, + diagnostic, + .. + } => { + let cli = probe_args.first().map(String::as_str).unwrap_or("the CLI"); + let config_file = format!("~/.{}/config.toml", cli); + format!( + "{} is invalid: {} — fix the config and restart the agent", + config_file, diagnostic + ) + } + RequirementPayload::GitBash => { + "install Git for Windows (open Doctor in Settings to diagnose)".to_string() + } + } + } +} + +/// The full setup payload passed by the desktop when spawning in setup mode. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SetupPayload { + /// Human-readable agent display name (for the nudge message). + pub agent_name: String, + /// Hex-encoded agent pubkey. Carried so the desktop card can open + /// the Edit Agent dialog for this agent directly from the nudge. + pub agent_pubkey: String, + /// Surface-discriminated list of missing requirements. + pub requirements: Vec, +} + +impl SetupPayload { + /// Read and deserialize the setup payload from the env var, if present. + /// + /// Returns `Ok(None)` when the env var is absent (normal mode). + /// Returns `Err` if the var is present but malformed. + pub(crate) fn from_env() -> Result> { + Self::from_raw_env_value(std::env::var(SETUP_PAYLOAD_ENV_VAR).ok()) + } + + /// Parse an optional raw env-var value into a `SetupPayload`. + /// + /// `None` or empty string → `Ok(None)` (normal mode, no setup payload). + /// Non-empty, valid JSON → `Ok(Some(payload))`. + /// Non-empty, malformed JSON → `Err`. + /// + /// This is the pure core of `from_env()` and is the preferred target for + /// unit tests — it requires no global env mutation and is safe to call + /// concurrently. + pub(crate) fn from_raw_env_value(raw: Option) -> Result> { + let raw = match raw { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + let payload = serde_json::from_str::(&raw) + .map_err(|e| anyhow::anyhow!("malformed {SETUP_PAYLOAD_ENV_VAR}: {e}"))?; + Ok(Some(payload)) + } + + /// Build the nudge message body from the requirements. + /// + /// The body contains two parts separated by a blank line: + /// 1. Human-readable markdown (unchanged; used by CLI and non-card clients). + /// 2. A fenced `buzz:config-nudge` sentinel block containing the structured + /// payload as JSON. The desktop client parses this block to render a + /// `ConfigNudgeCard`; clients that don't understand it see a code block. + fn nudge_body(&self) -> String { + let prose = if self.requirements.is_empty() { + format!( + "**{}** needs configuration before it can respond. Open Edit Agent to configure it.", + self.agent_name, + ) + } else { + let steps: Vec = self + .requirements + .iter() + .map(|r| format!("- {}", r.instruction())) + .collect(); + + let has_doctor_requirement = self + .requirements + .iter() + .any(|r| matches!(r, RequirementPayload::GitBash)); + let all_external = self + .requirements + .iter() + .all(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. })); + let any_external = self + .requirements + .iter() + .any(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. })); + + let footer = if has_doctor_requirement { + "Open Doctor in the Buzz app, install Git for Windows, then re-check and restart the agent.".to_string() + } else if all_external { + // All requirements are external config files — Edit Agent cannot + // help. Don't send the user there. + "Fix the config file(s) and restart the agent.".to_string() + } else if any_external { + // Mixed: some Buzz-managed fields, some external config. + "Open Edit Agent in the Buzz app for the Buzz-managed fields; fix the external CLI config files manually and restart the agent.".to_string() + } else { + // All Buzz-managed — original footer unchanged. + "Open Edit Agent in the Buzz app to set these.".to_string() + }; + + format!( + "**{}** needs configuration before it can respond:\n{}\n\n{}", + self.agent_name, + steps.join("\n"), + footer, + ) + }; + + // SAFETY: `self` is fully `Serialize`; this can only fail if the types + // contain non-string map keys, which they don't — panic is acceptable. + let sentinel_json = + serde_json::to_string(self).expect("SetupPayload must be serializable to JSON"); + + format!("{}\n\n```buzz:config-nudge\n{}\n```", prose, sentinel_json) + } +} + +// ── setup listener ──────────────────────────────────────────────────────────── + +/// Run the setup-mode event loop. +/// +/// Connects to the relay, subscribes to channels, and responds to @mentions +/// of this agent with a surface-correct setup nudge. Never starts the agent +/// pool. Reconnects on relay close (mirroring normal mode) so the nudge +/// listener survives transient disconnects; `nudged_event_ids` deduplication +/// guards against replay on reconnect. +pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> Result<()> { + tracing::info!( + agent = %payload.agent_name, + requirements = payload.requirements.len(), + "buzz-acp entering setup mode" + ); + + let pubkey_hex = config.keys.public_key().to_hex(); + + // Parse BUZZ_AUTH_TAG for relay membership / NIP-OA. + let relay_auth_tag: Option = std::env::var("BUZZ_AUTH_TAG") + .ok() + .filter(|s| !s.is_empty()) + .and_then(|s| buzz_sdk::nip_oa::parse_auth_tag(&s).ok()); + + let startup_watermark: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut relay = + HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag) + .await + .map_err(|e| anyhow::anyhow!("setup-mode relay connect error: {e}"))?; + + if let Err(e) = relay.set_startup_watermark(startup_watermark).await { + tracing::warn!("setup-mode: failed to set startup watermark: {e}"); + } + + relay + .subscribe_membership_notifications() + .await + .map_err(|e| anyhow::anyhow!("setup-mode membership subscribe error: {e}"))?; + + tracing::info!("setup-mode: connected and subscribed to membership notifications"); + + // Resolve owner for author-gate (same priority as normal mode). + let startup_owner = crate::resolve_agent_owner(&config); + let owner_cache = crate::OwnerCache::new(startup_owner); + + // Discover channels and subscribe (using a "mentions" rule so we get + // notified when someone @-mentions the agent). + let channel_info_map = relay + .discover_channels() + .await + .map_err(|e| anyhow::anyhow!("setup-mode channel discovery error: {e}"))?; + + tracing::info!( + "setup-mode: discovered {} channel(s)", + channel_info_map.len() + ); + + let channel_ids: Vec = channel_info_map.keys().copied().collect(); + + // Build subscription rules: mentions only (setup mode must not react to + // every message in a channel). + let rules = build_setup_subscription_rules(&config); + + let channel_filters = crate::config::resolve_channel_filters(&config, &channel_ids, &rules); + + if channel_filters.is_empty() { + tracing::warn!( + "setup-mode: no channel subscriptions resolved — nudge listener will sit idle" + ); + } + + for (channel_id, filter) in &channel_filters { + if let Err(e) = relay.subscribe_channel(*channel_id, filter.clone()).await { + tracing::warn!("setup-mode: failed to subscribe to channel {channel_id}: {e}"); + } else { + tracing::info!("setup-mode: subscribed to channel {channel_id}"); + } + } + + let publisher = relay.event_publisher(); + let rest_client = relay.rest_client(); + + // Deduplicate by event-id so reconnect replay cannot double-nudge. + let mut nudged_event_ids: HashSet = HashSet::new(); + + loop { + let Some(buzz_event) = relay.next_event().await else { + tracing::warn!("setup-mode: relay event stream ended — requesting reconnect"); + if let Err(e) = relay.reconnect().await { + tracing::error!("setup-mode: relay background task is gone: {e} — exiting"); + break; + } + continue; + }; + + let kind_u32 = buzz_event.event.kind.as_u16() as u32; + + // Handle membership notifications so we subscribe to new channels + // and drop removed ones — no session/queue drain needed (no pool). + if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION + || kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION + { + handle_setup_membership(&mut relay, &buzz_event, &config, &rules, &channel_ids).await; + continue; + } + + // Ignore non-message kinds (relay housekeeping, etc.). + if kind_u32 != KIND_STREAM_MESSAGE && kind_u32 != KIND_WORKFLOW_APPROVAL_REQUESTED { + continue; + } + + // ignore_self: don't react to our own messages. + if buzz_event.event.pubkey.to_hex() == pubkey_hex { + continue; + } + + // Require an explicit @mention of this agent — setup mode must not + // nudge on every channel event even if subscribe_mode is "all". + if !event_mentions_agent(&buzz_event.event, &pubkey_hex) { + continue; + } + + // Apply the same author gate as normal mode so the nudge only goes + // to authors the real agent would have answered. + let author_hex = buzz_event.event.pubkey.to_hex(); + let allowed = author_allowed( + &config.respond_to, + &config.respond_to_allowlist, + &author_hex, + &owner_cache, + &rest_client, + ) + .await; + + // Apply channel/kind filter rules. + let filter_matched = filter::match_event( + &buzz_event.event, + buzz_event.channel_id, + &rules, + &pubkey_hex, + ) + .await + .is_some(); + + // Pure gate: author gate verdict + event-id dedup. + if !should_nudge_for_event( + buzz_event.event.id, + allowed, + filter_matched, + &mut nudged_event_ids, + ) { + continue; + } + + // Build and publish the setup nudge. + if let Err(e) = publish_setup_nudge( + &publisher, + &config.keys, + buzz_event.channel_id, + &buzz_event.event, + &payload, + ) + .await + { + tracing::warn!("setup-mode: failed to publish nudge: {e}"); + } else { + tracing::info!( + channel_id = %buzz_event.channel_id, + event_id = %buzz_event.event.id, + "setup-mode: nudge published" + ); + } + } + + Ok(()) +} + +/// Outcome of the pure per-event gate checks in setup mode. +/// +/// Callers compute the async gates (`author_allowed`, `filter::match_event`) +/// up-front, then pass the boolean results here. This helper handles +/// everything that is synchronous and stateful: the author gate verdict +/// and event-id dedup. +/// +/// Returns `true` when the event should produce a nudge. +#[must_use] +pub(crate) fn should_nudge_for_event( + event_id: EventId, + author_allowed: bool, + filter_matched: bool, + nudged_event_ids: &mut HashSet, +) -> bool { + if !author_allowed { + tracing::debug!("setup-mode: event filtered by author gate"); + return false; + } + if !filter_matched { + return false; + } + if !nudged_event_ids.insert(event_id) { + tracing::debug!(%event_id, "setup-mode: skipping already-nudged event"); + return false; + } + true +} + +/// Build the subscription rules used in setup mode. +/// +/// Always uses "mentions" mode: setup mode must not react to every event. +/// Even if the agent's normal config is `subscribe_mode = all`, setup mode +/// enforces explicit @mention filtering (see `event_mentions_agent` call in +/// the loop above). +fn build_setup_subscription_rules(config: &Config) -> Vec { + use crate::config::SubscribeMode; + + let kinds = config + .kinds_override + .clone() + .unwrap_or_else(|| vec![KIND_STREAM_MESSAGE, KIND_WORKFLOW_APPROVAL_REQUESTED]); + + match &config.subscribe_mode { + // Config mode: load the actual rules, but they will be filtered by + // the explicit event_mentions_agent check in the main loop anyway. + SubscribeMode::Config => match crate::config::load_rules(&config.config_path) { + Ok(rules) => rules, + Err(e) => { + tracing::warn!( + "setup-mode: could not load config rules ({e}); falling back to mentions" + ); + vec![mentions_rule(kinds)] + } + }, + _ => vec![mentions_rule(kinds)], + } +} + +fn mentions_rule(kinds: Vec) -> filter::SubscriptionRule { + use std::sync::{atomic::AtomicU32, Arc}; + filter::SubscriptionRule { + name: "setup-mentions".into(), + channels: filter::ChannelScope::All("all".into()), + kinds, + require_mention: true, + filter: None, + compiled_filter: None, + consecutive_timeouts: Arc::new(AtomicU32::new(0)), + prompt_tag: Some("@mention".into()), + } +} + +/// Handle membership add/remove events in setup mode. +/// +/// Subscribe new channels; unsubscribe removed ones. No queue/session +/// teardown — there is no pool. +async fn handle_setup_membership( + relay: &mut HarnessRelay, + buzz_event: &crate::relay::BuzzEvent, + config: &Config, + rules: &[filter::SubscriptionRule], + _initial_channel_ids: &[Uuid], +) { + let kind_u32 = buzz_event.event.kind.as_u16() as u32; + let channel_id = buzz_event.channel_id; + + if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION { + // Subscribe to the newly-joined channel. + let ids = vec![channel_id]; + let filters = crate::config::resolve_channel_filters(config, &ids, rules); + for (cid, filter) in filters { + if let Err(e) = relay.subscribe_channel(cid, filter).await { + tracing::warn!("setup-mode: failed to subscribe to new channel {cid}: {e}"); + } else { + tracing::info!("setup-mode: subscribed to new channel {cid}"); + } + } + } else if kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION { + if let Err(e) = relay.unsubscribe_channel(channel_id).await { + tracing::warn!("setup-mode: failed to unsubscribe from channel {channel_id}: {e}"); + } + } +} + +/// Build and publish a setup nudge reply to the triggering event. +/// +/// Threading: flat reply to the thread root if one exists; otherwise reply +/// to the triggering event itself. P-tags the asker. +async fn publish_setup_nudge( + publisher: &RelayEventPublisher, + keys: &nostr::Keys, + channel_id: Uuid, + triggering_event: &nostr::Event, + payload: &SetupPayload, +) -> Result<()> { + use buzz_sdk::ThreadRef; + + // Parse NIP-10 thread tags to determine reply target. + let thread_tags = crate::queue::parse_thread_tags(triggering_event); + + let thread_ref = if let Some(root_str) = &thread_tags.root_event_id { + // Threaded event: reply flat to the root. + let root_id = nostr::EventId::from_hex(root_str) + .map_err(|e| anyhow::anyhow!("invalid root event id: {e}"))?; + Some(ThreadRef { + root_event_id: root_id, + parent_event_id: root_id, + }) + } else { + // Top-level event: reply to the triggering event. + Some(ThreadRef { + root_event_id: triggering_event.id, + parent_event_id: triggering_event.id, + }) + }; + + let body = payload.nudge_body(); + let author_hex = triggering_event.pubkey.to_hex(); + + let event_builder = buzz_sdk::build_message( + channel_id, + &body, + thread_ref.as_ref(), + &[&author_hex], // p-tag the asker + false, + &[], + ) + .map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?; + + let signed = event_builder + .sign_with_keys(keys) + .map_err(|e| anyhow::anyhow!("failed to sign setup nudge: {e}"))?; + + publisher + .publish_event(signed) + .await + .map_err(|e| anyhow::anyhow!("failed to publish setup nudge: {e}"))?; + + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn setup_payload_from_raw_returns_none_when_absent() { + // None → Ok(None): normal startup, no setup payload. + let result = SetupPayload::from_raw_env_value(None).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn setup_payload_from_raw_returns_none_on_empty_string() { + // Empty string → Ok(None): treated the same as absent. + let result = SetupPayload::from_raw_env_value(Some(String::new())).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn setup_payload_from_raw_returns_err_on_malformed_json() { + // Malformed JSON → Err: no global env mutation, safe to run concurrently. + let result = SetupPayload::from_raw_env_value(Some("not-valid-json{{{".into())); + assert!(result.is_err(), "malformed JSON must return Err"); + } + + #[test] + fn setup_payload_deserializes_correctly() { + let json = r#"{ + "agent_name": "Fizz", + "agent_pubkey": "aabbccddeeff0011", + "requirements": [ + {"surface": "normalized_field", "field": "provider"}, + {"surface": "env_key", "key": "ANTHROPIC_API_KEY"} + ] + }"#; + let payload: SetupPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.agent_name, "Fizz"); + assert_eq!(payload.requirements.len(), 2); + } + + #[test] + fn setup_payload_deserializes_git_bash_requirement() { + let payload: SetupPayload = serde_json::from_str( + r#"{"agent_name":"Buzz Agent","agent_pubkey":"test","requirements":[{"surface":"git_bash"}]}"#, + ) + .unwrap(); + assert!(matches!( + payload.requirements.as_slice(), + [RequirementPayload::GitBash] + )); + } + + #[test] + fn nudge_body_names_all_requirements() { + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![ + RequirementPayload::NormalizedField { + field: "provider".to_string(), + }, + RequirementPayload::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }, + ], + }; + let body = payload.nudge_body(); + assert!( + body.contains("provider"), + "nudge body should mention the missing provider field" + ); + assert!( + body.contains("ANTHROPIC_API_KEY"), + "nudge body should mention the missing env key" + ); + assert!(body.contains("Fizz"), "nudge body should name the agent"); + } + + #[test] + fn nudge_body_codex_copy_does_not_mention_openai_api_key() { + let payload = SetupPayload { + agent_name: "Codex".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![RequirementPayload::CliLogin { + probe_args: vec![ + "codex".to_string(), + "login".to_string(), + "status".to_string(), + ], + setup_copy: "run `codex login`".to_string(), + availability: AcpAvailabilityStatus::Available, + }], + }; + let body = payload.nudge_body(); + assert!( + !body.contains("OPENAI_API_KEY"), + "codex nudge must not mention OPENAI_API_KEY; got: {body:?}" + ); + assert!( + body.contains("codex login"), + "codex nudge must mention `codex login`; got: {body:?}" + ); + } + + #[test] + fn nudge_body_empty_requirements_falls_back_to_generic() { + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![], + }; + let body = payload.nudge_body(); + assert!(body.contains("Fizz")); + assert!(body.contains("needs configuration")); + } + + // ── config-invalid footer tests ──────────────────────────────────────────── + + fn make_cli_config_invalid(cli: &str, diagnostic: &str) -> RequirementPayload { + RequirementPayload::CliConfigInvalid { + probe_args: vec![cli.to_string()], + setup_copy: String::new(), + diagnostic: diagnostic.to_string(), + } + } + + #[test] + fn nudge_body_all_config_invalid_omits_edit_agent_footer() { + // An all-CliConfigInvalid requirements list must NOT send users to + // Edit Agent (which cannot fix an external config file). + let payload = SetupPayload { + agent_name: "Codex".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![make_cli_config_invalid( + "codex", + "unknown variant `ultra` for field `model_reasoning_effort`", + )], + }; + let body = payload.nudge_body(); + assert!( + !body.contains("Open Edit Agent"), + "all-config-invalid nudge must not mention Open Edit Agent; got: {body:?}" + ); + assert!( + body.contains("config.toml"), + "nudge must name the config file; got: {body:?}" + ); + assert!( + body.contains("restart the agent"), + "nudge must include restart guidance; got: {body:?}" + ); + } + + #[test] + fn nudge_body_mixed_requirements_uses_split_footer() { + // Mixed list: one Buzz-managed env key + one external config invalid. + // Footer must address both sides. + let payload = SetupPayload { + agent_name: "Codex".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![ + RequirementPayload::EnvKey { + key: "SOME_API_KEY".to_string(), + }, + make_cli_config_invalid("codex", "unknown variant `gpt-5.6-sol`"), + ], + }; + let body = payload.nudge_body(); + assert!( + body.contains("Open Edit Agent"), + "mixed nudge must mention Open Edit Agent for managed fields; got: {body:?}" + ); + assert!( + body.contains("fix the external CLI config"), + "mixed nudge must mention fixing the external config; got: {body:?}" + ); + } + + #[test] + fn nudge_body_all_buzz_managed_retains_original_footer() { + // Pure Buzz-managed requirements → original "Open Edit Agent" footer unchanged. + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![RequirementPayload::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }], + }; + let body = payload.nudge_body(); + assert!( + body.contains("Open Edit Agent in the Buzz app to set these."), + "all-managed nudge must use the original Edit Agent footer; got: {body:?}" + ); + } + + // ── sentinel block tests ─────────────────────────────────────────────────── + + #[test] + fn nudge_body_contains_sentinel_block() { + // The body must end with a ```buzz:config-nudge fence so the desktop + // can detect and strip it before rendering the ConfigNudgeCard. + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![RequirementPayload::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }], + }; + let body = payload.nudge_body(); + assert!( + body.contains("```buzz:config-nudge\n"), + "body must open the sentinel fence; got: {body:?}" + ); + assert!( + body.ends_with("```"), + "body must close the sentinel fence; got: {body:?}" + ); + } + + #[test] + fn nudge_body_sentinel_round_trips_payload() { + // The JSON inside the sentinel block must deserialize back to an + // equivalent SetupPayload (same agent_name and requirements). + let payload = SetupPayload { + agent_name: "Atlas".to_string(), + agent_pubkey: "ddeeff00".to_string(), + requirements: vec![ + RequirementPayload::NormalizedField { + field: "model".to_string(), + }, + RequirementPayload::EnvKey { + key: "OPENAI_API_KEY".to_string(), + }, + RequirementPayload::CliLogin { + probe_args: vec!["codex".to_string(), "login".to_string()], + setup_copy: "run `codex login`".to_string(), + availability: AcpAvailabilityStatus::Available, + }, + ], + }; + let body = payload.nudge_body(); + + // Extract the JSON between the fence markers. + let fence_open = "```buzz:config-nudge\n"; + let fence_close = "\n```"; + let start = body + .rfind(fence_open) + .expect("sentinel open fence not found") + + fence_open.len(); + let end = body[start..] + .rfind(fence_close) + .expect("sentinel close fence not found") + + start; + let json = &body[start..end]; + + let recovered: SetupPayload = + serde_json::from_str(json).expect("sentinel JSON must deserialize"); + assert_eq!(recovered.agent_name, payload.agent_name); + assert_eq!(recovered.agent_pubkey, payload.agent_pubkey); + assert_eq!(recovered.requirements.len(), payload.requirements.len()); + } + + #[test] + fn nudge_body_prose_still_present_with_sentinel() { + // Existing prose checks must pass — the sentinel is APPENDED, not a + // replacement, so all prior `body.contains(...)` invariants hold. + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![ + RequirementPayload::NormalizedField { + field: "provider".to_string(), + }, + RequirementPayload::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }, + ], + }; + let body = payload.nudge_body(); + assert!(body.contains("provider"), "prose must name the field"); + assert!( + body.contains("ANTHROPIC_API_KEY"), + "prose must name the key" + ); + assert!(body.contains("Fizz"), "prose must name the agent"); + // Sentinel is also present. + assert!( + body.contains("```buzz:config-nudge"), + "sentinel must follow" + ); + } + + // ── should_nudge_for_event gate tests ───────────────────────────────────── + // + // These tests exercise the loop-wiring for the two safety-critical guards: + // (a) non-allowlisted author → no nudge, (b) same event-id → exactly one + // nudge. They use the extracted `should_nudge_for_event` helper, which is + // the exact code the live loop calls. + + fn fake_event_id(byte: u8) -> EventId { + EventId::from_byte_array([byte; 32]) + } + + #[test] + fn test_non_allowlisted_author_returns_no_nudge() { + // author_allowed = false → should return false regardless of other args. + let mut dedup: HashSet = HashSet::new(); + let event_id = fake_event_id(0xAA); + + let result = should_nudge_for_event( + event_id, false, // author NOT allowed + true, // filter matched — would otherwise nudge + &mut dedup, + ); + + assert!(!result, "non-allowlisted author must not produce a nudge"); + // Dedup set must remain empty — no phantom insertion for blocked author. + assert!( + dedup.is_empty(), + "dedup set must not record event for blocked author" + ); + } + + #[test] + fn test_same_event_id_twice_nudges_exactly_once() { + // The first call with a given event-id should return true; the second + // call with the identical id must return false (replay dedup). + let mut dedup: HashSet = HashSet::new(); + let event_id = fake_event_id(0xBB); + + let first = should_nudge_for_event( + event_id, true, // allowed + true, // matched + &mut dedup, + ); + assert!(first, "first occurrence must be accepted"); + + // Simulate reconnect replay: same event arrives again. + let second = should_nudge_for_event( + event_id, true, // allowed + true, // matched + &mut dedup, + ); + assert!( + !second, + "replay of the same event-id must be rejected (dedup)" + ); + } + + // ── availability round-trip tests ───────────────────────────────────────── + // + // These tests prove the desktop→buzz-acp→sentinel path preserves the + // `availability` field. They simulate what actually happens at runtime: + // desktop serializes a `cli_login` JSON blob → buzz-acp parses it via + // `from_raw_env_value` → `nudge_body()` re-serializes into the sentinel → + // the sentinel JSON is extracted and checked for the `availability` field. + // + // This guards the prior regression where `RequirementPayload::CliLogin` + // had no `availability` field, so serde silently dropped it during + // deserialization and the desktop card never rendered. + + fn extract_sentinel_json(body: &str) -> String { + let fence_open = "```buzz:config-nudge\n"; + let fence_close = "\n```"; + let start = body + .rfind(fence_open) + .expect("sentinel open fence not found") + + fence_open.len(); + let end = body[start..] + .rfind(fence_close) + .expect("sentinel close fence not found") + + start; + body[start..end].to_string() + } + + fn make_desktop_cli_login_json(availability: &str) -> String { + // Simulate the JSON desktop's runtime.rs emits — a full SetupPayload + // with one cli_login requirement carrying the given availability state. + format!( + r#"{{"agent_name":"TestAgent","agent_pubkey":"aa","requirements":[{{"surface":"cli_login","probe_args":["claude"],"setup_copy":"run claude login","availability":"{availability}"}}]}}"# + ) + } + + #[test] + fn cli_login_availability_available_survives_sentinel_round_trip() { + let raw = make_desktop_cli_login_json("available"); + let payload = SetupPayload::from_raw_env_value(Some(raw)) + .unwrap() + .expect("must parse"); + let body = payload.nudge_body(); + let sentinel_json = extract_sentinel_json(&body); + assert!( + sentinel_json.contains(r#""availability":"available""#), + "sentinel must carry availability=available; got: {sentinel_json:?}" + ); + } + + #[test] + fn cli_login_availability_adapter_missing_survives_sentinel_round_trip() { + let raw = make_desktop_cli_login_json("adapter_missing"); + let payload = SetupPayload::from_raw_env_value(Some(raw)) + .unwrap() + .expect("must parse"); + let body = payload.nudge_body(); + let sentinel_json = extract_sentinel_json(&body); + assert!( + sentinel_json.contains(r#""availability":"adapter_missing""#), + "sentinel must carry availability=adapter_missing; got: {sentinel_json:?}" + ); + } + + #[test] + fn cli_login_availability_cli_missing_survives_sentinel_round_trip() { + let raw = make_desktop_cli_login_json("cli_missing"); + let payload = SetupPayload::from_raw_env_value(Some(raw)) + .unwrap() + .expect("must parse"); + let body = payload.nudge_body(); + let sentinel_json = extract_sentinel_json(&body); + assert!( + sentinel_json.contains(r#""availability":"cli_missing""#), + "sentinel must carry availability=cli_missing; got: {sentinel_json:?}" + ); + } + + #[test] + fn cli_login_availability_not_installed_survives_sentinel_round_trip() { + let raw = make_desktop_cli_login_json("not_installed"); + let payload = SetupPayload::from_raw_env_value(Some(raw)) + .unwrap() + .expect("must parse"); + let body = payload.nudge_body(); + let sentinel_json = extract_sentinel_json(&body); + assert!( + sentinel_json.contains(r#""availability":"not_installed""#), + "sentinel must carry availability=not_installed; got: {sentinel_json:?}" + ); + } +} diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index f581d91203..a4f7abd3b3 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -86,6 +86,10 @@ pub(crate) struct UsageUpdatePayload { pub accumulated_input_tokens: u64, pub accumulated_output_tokens: u64, pub accumulated_cost: Option, + /// Effective model id for this turn. Optional — goose payloads that + /// predate this field deserialize cleanly as `None`. + #[serde(default)] + pub model: Option, } /// Per-session normalization state: the last cumulative snapshot we saw. @@ -130,6 +134,9 @@ pub struct TurnUsage { pub cumulative_output_tokens: u64, /// Session-cumulative estimated cost in USD; `None` if goose did not report it. pub cumulative_cost_usd: Option, + /// Effective model id for this turn (maps to NIP-AM `model`). `None` if the + /// harness did not include the model in its usage notification. + pub model: Option, } /// Tracks per-session cumulative usage state across turns. @@ -262,6 +269,7 @@ impl UsageTracker { cumulative_input_tokens: current_input, cumulative_output_tokens: current_output, cumulative_cost_usd: current_cost, + model: payload.model.clone(), }); } else if self.in_flight_session.is_none() { // Not in-flight at all: advance the committed baseline so the next @@ -322,6 +330,7 @@ mod tests { accumulated_input_tokens: input, accumulated_output_tokens: output, accumulated_cost: cost, + model: None, } } @@ -332,6 +341,7 @@ mod tests { accumulated_input_tokens: input, accumulated_output_tokens: output, accumulated_cost: cost, + model: None, } } @@ -812,4 +822,71 @@ mod tests { "take() without any record() must return None (pre-response cancel path)" ); } + + // ── model field threading ──────────────────────────────────────────────── + + fn payload_with_model( + input: u64, + output: u64, + cost: Option, + model: Option<&str>, + ) -> UsageUpdatePayload { + UsageUpdatePayload { + used: input + output, + context_limit: 200_000, + accumulated_input_tokens: input, + accumulated_output_tokens: output, + accumulated_cost: cost, + model: model.map(str::to_string), + } + } + + #[test] + fn model_threads_from_payload_to_turn_usage() { + // When a `usage_update` payload includes a `model` field, TurnUsage + // must carry it through so pool.rs can populate the 44200 payload. + let mut tracker = UsageTracker::default(); + let p = payload_with_model(1000, 200, None, Some("claude-sonnet-4-5")); + tracker.begin_turn("sess-model"); + tracker.record("sess-model", &p); + let usage = tracker.take().expect("pending"); + assert_eq!( + usage.model.as_deref(), + Some("claude-sonnet-4-5"), + "model must pass through record() → pending → take()" + ); + } + + #[test] + fn model_none_when_payload_omits_model_field() { + // Goose payloads that predate the `model` field must deserialize cleanly + // and produce TurnUsage with model = None (no deserialization error, + // no panic — goose-parity / fail-soft contract). + let json = r#"{ + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 500, + "accumulatedOutputTokens": 100, + "accumulatedCost": 0.005 + }"#; + let variant: GooseSessionUpdateVariant = + serde_json::from_str(json).expect("must deserialize without model field"); + let payload = match variant { + GooseSessionUpdateVariant::UsageUpdate(p) => p, + _ => panic!("expected UsageUpdate variant"), + }; + assert!( + payload.model.is_none(), + "model must be None when absent from wire payload" + ); + + // And it should produce a TurnUsage with model = None. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-goose-compat"); + tracker.record("sess-goose-compat", &payload); + let usage = tracker.take().expect("pending"); + assert!( + usage.model.is_none(), + "TurnUsage.model must be None when payload omits the field" + ); + } } diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 1000b36162..bb30ddfae4 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -76,6 +76,11 @@ enum Command { GenerateKey, /// Run pending database migrations. Migrate, + /// Inspect deployment-wide Buzz product feedback. + ProductFeedback { + #[command(subcommand)] + command: ProductFeedbackCommand, + }, /// Emit kind:39000/39002 events for channels missing them. /// /// Channels created via direct SQL (seed scripts, pre-migration data) won't @@ -90,6 +95,16 @@ enum Command { }, } +#[derive(Subcommand)] +enum ProductFeedbackCommand { + /// List feedback across every community as JSON. + List { + /// Maximum records to return. + #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))] + limit: u16, + }, +} + #[tokio::main] async fn main() { // Install the ring CryptoProvider for rustls. The workspace redis TLS @@ -130,6 +145,9 @@ async fn run(cli: Cli) -> Result { Command::AddMember { pubkey, role } => cmd_add_member(pubkey, role).await, Command::RemoveMember { pubkey, role } => cmd_remove_member(pubkey, role).await, Command::ListMembers => cmd_list_members().await, + Command::ProductFeedback { + command: ProductFeedbackCommand::List { limit }, + } => cmd_list_product_feedback(limit).await, Command::ReconcileChannels { relay_key } => { reconcile_channels(relay_key).await?; Ok(0) @@ -232,6 +250,13 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R Ok(0) } +async fn cmd_list_product_feedback(limit: u16) -> Result { + let db = connect_db().await?; + let feedback = db.list_product_feedback(i64::from(limit)).await?; + println!("{}", serde_json::to_string_pretty(&feedback)?); + Ok(0) +} + async fn cmd_list_members() -> Result { let db = connect_db().await?; let tenant = resolve_admin_tenant(&db).await?; diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 606edf2d25..f06849c7d1 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -12,8 +12,8 @@ pub const PROTOCOL_VERSION: u32 = 2; /// - **Anthropic adaptive**: `low|medium|high|xhigh|max` (model-dependent; see `anthropic_thinking_config`). /// `none`/`minimal` are not Anthropic values — rejected at startup. /// - **Anthropic manual budget** (claude-3*, opus-4-5): `low|medium|high`; `xhigh`/`max` clamp to high budget. -/// - **OpenAI Responses / Chat Completions**: `none|minimal|low|medium|high|xhigh` (provider pass-through). -/// `max` is not an OpenAI value — rejected at startup. +/// - **OpenAI Responses / Chat Completions**: effort support is model-dependent and normalized at +/// request time; `max` is valid for documented max-supporting families such as GPT-5.6. /// - **Databricks**: routed by model family (Claude → Anthropic mapping, GPT-5 → Responses, MLflow → Chat). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ThinkingEffort { @@ -70,6 +70,32 @@ impl ThinkingEffort { } } +/// Strip any endpoint-naming prefix from a model name so the family classifiers +/// (`is_manual_budget_model`, `is_adaptive_thinking_model`, etc.) can match on the canonical +/// `claude-*` form regardless of how the model is stored in the Databricks catalog. +/// +/// Rather than maintaining an allowlist of known prefixes, this function finds the first +/// occurrence of a known model-family token (`claude-`, `gpt-`) and drops everything before +/// it. This handles any endpoint naming convention without needing to enumerate prefixes. +/// +/// Examples: +/// - `databricks-claude-fable-5` → `claude-fable-5` +/// - `goose-claude-fable-5` → `claude-fable-5` +/// - `team-x-claude-opus-4-7` → `claude-opus-4-7` +/// - `goose-gpt-5.5` → `gpt-5.5` +/// - `llama-3` → `llama-3` (no family token, returned unchanged) +/// +/// If no family token is present the name is returned unchanged. +fn strip_catalog_prefix(model: &str) -> &str { + const FAMILY_TOKENS: &[&str] = &["claude-", "gpt-"]; + let lower = model.to_ascii_lowercase(); + let first_idx = FAMILY_TOKENS.iter().filter_map(|tok| lower.find(tok)).min(); + match first_idx { + Some(idx) => &model[idx..], + None => model, + } +} + /// Build the Anthropic thinking/effort request fields for the given model and effort level. /// /// API shape selection (per Anthropic extended-thinking support table, @@ -90,8 +116,9 @@ impl ThinkingEffort { /// **Everything else** — omit both fields. This includes unknown/future `claude-*` names /// not yet in the support table. Safer to omit than to guess an unverified shape. /// -/// The Databricks `databricks-` prefix is stripped before matching so that -/// `databricks-claude-opus-4-7` routes to the adaptive bucket. +/// The Databricks `databricks-` and other endpoint-naming prefixes are stripped before +/// matching so that `databricks-claude-opus-4-7`, `goose-claude-fable-5`, and +/// `team-x-claude-opus-4-7` all route to the correct bucket. See `strip_catalog_prefix`. /// /// Returns `(thinking_field, output_config_field)` where each is `None` if not applicable. pub fn anthropic_thinking_config( @@ -100,11 +127,11 @@ pub fn anthropic_thinking_config( max_output_tokens: u32, ) -> (Option, Option) { use serde_json::json; - // Normalise the model name for matching: strip Databricks gateway prefixes - // (e.g. "databricks-claude-opus-4-7" → "claude-opus-4-7"). - let model = effective_model - .strip_prefix("databricks-") - .unwrap_or(effective_model); + // Normalise the model name for matching: strip any endpoint-naming prefix + // (e.g. "databricks-claude-opus-4-7" → "claude-opus-4-7", + // "goose-claude-fable-5" → "claude-fable-5", + // "team-x-claude-opus-4-7" → "claude-opus-4-7"). + let model = strip_catalog_prefix(effective_model); if is_manual_budget_model(model) { // Manual-budget shape: budget_tokens must be strictly < max_tokens AND must leave @@ -148,6 +175,20 @@ pub fn anthropic_thinking_config( } } +/// Returns true for adaptive Anthropic models that support the `xhigh` effort level. +/// +/// Used by both `clamp_adaptive_effort` (request-time) and `anthropic_efforts_for_model` +/// (UI capability table) to keep xhigh-support classification in a single place. +/// +/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). +fn anthropic_model_supports_xhigh(model: &str) -> bool { + model.starts_with("claude-opus-4-7") + || model.starts_with("claude-opus-4-8") + || model.starts_with("claude-sonnet-5") + || model.starts_with("claude-fable-5") + || model.starts_with("claude-mythos-5") +} + /// Clamp the requested effort level to the highest doc-verified level for the given adaptive model. /// /// Doc-verified availability (Anthropic effort page, July 2025): @@ -160,16 +201,10 @@ pub fn anthropic_thinking_config( /// supported level below the requested one, and logs a warning. This is dynamic (not /// startup-time) because `session/set_model` can change the model after startup. /// -/// `model` must already have the `databricks-` prefix stripped. +/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). pub fn clamp_adaptive_effort(model: &str, effort: ThinkingEffort) -> ThinkingEffort { // Models that support all levels including xhigh (and max). - let supports_xhigh = model.starts_with("claude-opus-4-7") - || model.starts_with("claude-opus-4-8") - || model.starts_with("claude-sonnet-5") - || model.starts_with("claude-fable-5") - || model.starts_with("claude-mythos-5"); - // NOTE: claude-mythos-preview does NOT support xhigh (xhigh → clamp to high). - // All adaptive models support low/medium/high and max. + let supports_xhigh = anthropic_model_supports_xhigh(model); let clamped = if supports_xhigh { effort // all levels pass through @@ -275,27 +310,37 @@ fn gpt5_base_matches(lower_model: &str, token: &str) -> bool { /// | Model | Supported effort values | /// |-------------|-------------------------------------------| /// | gpt-5-pro | `high` only | +/// | gpt-5.6 | `none, low, medium, high, xhigh, max` | /// | gpt-5.5 | `none, low, medium, high, xhigh` | /// | gpt-5.4 | `none, low, medium, high, xhigh` | /// | gpt-5.1 | `none, low, medium, high` | /// | gpt-5 (base)| `minimal, low, medium, high` | -/// | unknown | not doc-verified — pass through unchanged | +/// | unknown | not doc-verified — `max` clamps to `xhigh` | /// /// Note the `none` vs `minimal` split: `gpt-5` (base) supports `minimal` but not `none`; -/// `gpt-5.1`/`gpt-5.4`/`gpt-5.5` support `none` but not `minimal`. These are matched via +/// `gpt-5.1`/`gpt-5.4`/`gpt-5.5`/`gpt-5.6` support `none` but not `minimal`. These are matched via /// nearest-supported fallback in `normalize_effort_for_openai_route`. /// /// Match order: `-pro` variant checked before versioned strings to prevent `gpt-5-pro` from /// falling into the `gpt-5` base bucket (substring "gpt-5" is shared). /// /// `model` is a raw model name (may include Databricks gateway prefixes or date suffixes). -/// Unknown models return `None` — caller treats `None` as "server-validated pass-through". +/// Unknown models return `None` — callers pass through values except `max`, which clamps to +/// `xhigh` until support is confirmed. /// Versioned tokens use `gpt5_token_matches` (end-of-string or `-` boundary, blocking digit /// and letter continuations). The base token uses `gpt5_base_matches`, which additionally /// rejects short `-<1-3 digit>` suffixes that look like two-digit version numbers. fn openai_efforts_for_model(model: &str) -> Option<&'static [ThinkingEffort]> { // Effort ordered from lowest to highest for each family. const GPT5_PRO: &[ThinkingEffort] = &[ThinkingEffort::High]; + const GPT5_6: &[ThinkingEffort] = &[ + ThinkingEffort::None, + ThinkingEffort::Low, + ThinkingEffort::Medium, + ThinkingEffort::High, + ThinkingEffort::XHigh, + ThinkingEffort::Max, + ]; const GPT5_5_AND_5_4: &[ThinkingEffort] = &[ ThinkingEffort::None, ThinkingEffort::Low, @@ -321,6 +366,12 @@ fn openai_efforts_for_model(model: &str) -> Option<&'static [ThinkingEffort]> { // matching the base "gpt-5" prefix first. if gpt5_token_matches(&lower, "gpt-5-pro") || gpt5_token_matches(&lower, "gpt5-pro") { Some(GPT5_PRO) + } else if gpt5_token_matches(&lower, "gpt-5.6") + || gpt5_token_matches(&lower, "gpt5.6") + || gpt5_token_matches(&lower, "gpt-5-6") + || gpt5_token_matches(&lower, "gpt5-6") + { + Some(GPT5_6) } else if gpt5_token_matches(&lower, "gpt-5.5") || gpt5_token_matches(&lower, "gpt5.5") || gpt5_token_matches(&lower, "gpt-5-5") @@ -347,6 +398,58 @@ fn openai_efforts_for_model(model: &str) -> Option<&'static [ThinkingEffort]> { } } +/// Returns the effort capability set for a given Anthropic model. +/// +/// This is the single production source of truth for Anthropic family routing. +/// Both `anthropic_thinking_config` (request-time) and the effort-table UI +/// (`valid_effort_values_for_provider_model`, via its Anthropic branch) must +/// derive their behaviour from this helper so the two stay in sync. +/// +/// Returns `(valid_values, default)` where: +/// - `valid_values` is the static slice of `ThinkingEffort` values accepted +/// by this model family's effort dropdown. +/// - `default` is `None` for manual-budget models (no semantic default — +/// user must choose) or `Some(High)` for adaptive families. +/// +/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). +pub fn anthropic_efforts_for_model( + model: &str, +) -> (&'static [ThinkingEffort], Option) { + const MANUAL: &[ThinkingEffort] = &[ + ThinkingEffort::Low, + ThinkingEffort::Medium, + ThinkingEffort::High, + ]; + const ADAPTIVE_XHIGH: &[ThinkingEffort] = &[ + ThinkingEffort::Low, + ThinkingEffort::Medium, + ThinkingEffort::High, + ThinkingEffort::XHigh, + ThinkingEffort::Max, + ]; + const ADAPTIVE_NO_XHIGH: &[ThinkingEffort] = &[ + ThinkingEffort::Low, + ThinkingEffort::Medium, + ThinkingEffort::High, + ThinkingEffort::Max, + ]; + + if is_manual_budget_model(model) { + return (MANUAL, None); + } + if is_adaptive_thinking_model(model) { + // Reuse `anthropic_model_supports_xhigh` (the single source of truth + // shared with `clamp_adaptive_effort`) — no side-effects, no duplication. + if anthropic_model_supports_xhigh(model) { + return (ADAPTIVE_XHIGH, Some(ThinkingEffort::High)); + } else { + return (ADAPTIVE_NO_XHIGH, Some(ThinkingEffort::High)); + } + } + // Unknown Anthropic model — assume full adaptive (xhigh-capable) as a safe default. + (ADAPTIVE_XHIGH, Some(ThinkingEffort::High)) +} + /// Resolve the nearest supported effort level for a given OpenAI model. /// /// When the requested effort is not in the model's supported set, falls back to the @@ -356,8 +459,8 @@ fn openai_efforts_for_model(model: &str) -> Option<&'static [ThinkingEffort]> { /// model families means the "closest analogue" is the other form before jumping to `low`). /// - Above that pair: upward clamp first, then downward (prefer more thinking over less). /// - `xhigh` falls back to `high` when not supported (no model skips from `high` to `xhigh`). -/// - `max` is first clamped to `xhigh` by `normalize_effort_for_openai_route` before this -/// function is reached; this function never sees `max`. +/// - `max` passes through for model families whose table includes it; otherwise it resolves to +/// the nearest supported level. /// /// Logs a `warn!` on every substitution. fn resolve_openai_effort( @@ -418,42 +521,32 @@ fn resolve_openai_effort( /// Normalize the effort value for an OpenAI-shaped request body (Chat Completions or Responses). /// -/// Two normalizations are applied in order: -/// -/// 1. **`max` → `xhigh` clamp**: `max` is not a valid OpenAI reasoning effort value; clamped -/// at this step. The pure-OpenAI startup validator already rejects `max` at startup, so this -/// clamp only fires on `DatabricksV2` sessions that routed to the OpenAI path. -/// -/// 2. **Per-model effort availability**: for doc-verified OpenAI model families, the requested -/// level (post-clamp) is checked against the model's supported set. If not supported, the -/// nearest supported level is substituted (see `resolve_openai_effort` for preference order). -/// Unknown/unverified models are passed through unchanged — the server validates. +/// Per-model effort availability is applied for doc-verified OpenAI model families. A requested +/// level not in the model's supported set is substituted with the nearest supported level (see +/// `resolve_openai_effort` for preference order). For unknown/unverified models, `max` is clamped +/// to `xhigh` because its support cannot be confirmed; all other values pass through unchanged. /// /// Applies to pure-OpenAI request paths AND DBv2 OpenAI-shaped routes. /// /// Doc-verified model table (July 2025): /// - `gpt-5-pro`: `high` only +/// - `gpt-5.6`: `none, low, medium, high, xhigh, max` /// - `gpt-5.5`, `gpt-5.4`: `none, low, medium, high, xhigh` /// - `gpt-5.1`: `none, low, medium, high` /// - `gpt-5` (base): `minimal, low, medium, high` -/// - unknown: pass through (server-validated) +/// - unknown: `max` clamps to `xhigh`; other values pass through pub fn normalize_effort_for_openai_route(effort: ThinkingEffort, model: &str) -> ThinkingEffort { - // Step 1: clamp max → xhigh (max is not a valid OpenAI value). - let clamped = if effort == ThinkingEffort::Max { - tracing::warn!( - requested = "max", - resolved = "xhigh", - "BUZZ_AGENT_THINKING_EFFORT=max is not valid for OpenAI-shaped requests; clamping to xhigh" - ); - ThinkingEffort::XHigh - } else { - effort - }; - - // Step 2: per-model effort availability check. match openai_efforts_for_model(model) { - Some(supported) => resolve_openai_effort(model, clamped, supported), - None => clamped, // unknown model — pass through + Some(supported) => resolve_openai_effort(model, effort, supported), + None if effort == ThinkingEffort::Max => { + tracing::warn!( + requested = "max", + resolved = "xhigh", + "BUZZ_AGENT_THINKING_EFFORT=max not confirmed for unknown OpenAI model; clamping to xhigh" + ); + ThinkingEffort::XHigh + } + None => effort, } } @@ -489,7 +582,7 @@ pub fn normalize_effort_for_anthropic_route(effort: ThinkingEffort) -> Option bool { model.starts_with("claude-3") || model == "claude-opus-4-5" } @@ -506,7 +599,7 @@ fn is_manual_budget_model(model: &str) -> bool { /// Note: Opus 4.5 is NOT in this bucket — it uses manual budget (see `is_manual_budget_model`). /// No prefix wildcards over version numbers; each entry is doc-verified explicitly. /// -/// `model` must already have the `databricks-` prefix stripped. +/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). fn is_adaptive_thinking_model(model: &str) -> bool { // Exact version strings for Opus 4.x adaptive models (4.6, 4.7, 4.8). // Opus 4.5 is excluded — manual budget only. @@ -838,19 +931,13 @@ impl Config { } // Provider-level effort validation (fail-fast, clear error). // `none`/`minimal` are not Anthropic values — rejected at startup. - // `max` is not an OpenAI value — rejected at startup. - // Model-level clamping (e.g. xhigh on Opus 4.6) is dynamic: happens at request - // build time because `session/set_model` can change the model after startup. // - // DatabricksV2 is intentionally EXCLUDED from startup validation: it dispatches - // across Anthropic Messages, OpenAI Responses, and MLflow Chat routes at request - // build time based on the effective model. No single effort value is invalid for - // all three routes, so provider-wide startup rejection is wrong. Route-aware effort - // normalization is applied instead via `normalize_effort_for_openai_route` / - // `normalize_effort_for_anthropic_route` at request build time in `llm.rs`. + // OpenAI, Databricks, and DatabricksV2 defer effort validation to request-time routing: + // availability is model-dependent, and `session/set_model` can change the effective model + // after startup. `normalize_effort_for_openai_route` / `normalize_effort_for_anthropic_route` + // apply route-aware normalization in `llm.rs` when building each request. if let Some(effort) = self.thinking_effort { let is_pure_anthropic = matches!(self.provider, Provider::Anthropic); - let is_pure_openai = matches!(self.provider, Provider::OpenAi | Provider::Databricks); if is_pure_anthropic && matches!(effort, ThinkingEffort::None | ThinkingEffort::Minimal) { return Err(format!( @@ -859,13 +946,6 @@ impl Config { effort.openai_effort_str() )); } - if is_pure_openai && matches!(effort, ThinkingEffort::Max) { - return Err( - "config: BUZZ_AGENT_THINKING_EFFORT=max is not valid for OpenAI/Databricks \ - providers (allowed: none|minimal|low|medium|high|xhigh)" - .into(), - ); - } } Ok(()) } @@ -1526,6 +1606,45 @@ mod tests { assert_eq!(oc["effort"], "medium"); } + #[test] + fn anthropic_thinking_config_goose_prefix_stripped_for_fable_5() { + // "goose-" catalog prefix must be stripped so goose-claude-fable-5 routes to + // the adaptive + xhigh/max bucket, not the "unknown model → (None, None)" path. + let (thinking, output_config) = + anthropic_thinking_config("goose-claude-fable-5", ThinkingEffort::Max, 32_768); + let t = + thinking.expect("thinking:{type:adaptive} must be present for goose-claude-fable-5"); + assert_eq!(t["type"], "adaptive"); + let oc = output_config.expect("output_config must be present for goose-claude-fable-5"); + assert_eq!(oc["effort"], "max"); + } + + #[test] + fn anthropic_thinking_config_goose_prefix_stripped_for_sonnet_5() { + // Adaptive xhigh model via goose- prefix. + let (thinking, output_config) = + anthropic_thinking_config("goose-claude-sonnet-5", ThinkingEffort::XHigh, 32_768); + let t = + thinking.expect("thinking:{type:adaptive} must be present for goose-claude-sonnet-5"); + assert_eq!(t["type"], "adaptive"); + let oc = output_config.expect("output_config must be present for goose-claude-sonnet-5"); + assert_eq!(oc["effort"], "xhigh"); + } + + #[test] + fn anthropic_thinking_config_arbitrary_prefix_stripped_for_opus_4_7() { + // team-x-claude-opus-4-7: first claude- token at index 7 → strips "team-x-" + // Verifies the arbitrary-prefix normalization reaches anthropic_thinking_config + // end-to-end: UI exposes max as valid, and runtime must honor it. + let (thinking, output_config) = + anthropic_thinking_config("team-x-claude-opus-4-7", ThinkingEffort::Max, 32_768); + let t = + thinking.expect("thinking:{type:adaptive} must be present for team-x-claude-opus-4-7"); + assert_eq!(t["type"], "adaptive"); + let oc = output_config.expect("output_config must be present for team-x-claude-opus-4-7"); + assert_eq!(oc["effort"], "max"); + } + // ---- clamp_adaptive_effort — per-model clamping tests ---- #[test] @@ -1784,30 +1903,43 @@ mod tests { } #[test] - fn validate_rejects_max_effort_for_openai() { - let cfg = make_config_for_validation(Provider::OpenAi, Some(ThinkingEffort::Max)); - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("BUZZ_AGENT_THINKING_EFFORT=max"), - "error must name the value: {err}" - ); - assert!( - err.contains("not valid for OpenAI"), - "error must name the provider: {err}" - ); - assert!( - err.contains("none|minimal|low|medium|high|xhigh"), - "error must name allowed values: {err}" - ); + fn validate_accepts_all_efforts_for_openai() { + // OpenAI effort support is model-dependent and normalized at request build time. + for effort in [ + ThinkingEffort::None, + ThinkingEffort::Minimal, + ThinkingEffort::Low, + ThinkingEffort::Medium, + ThinkingEffort::High, + ThinkingEffort::XHigh, + ThinkingEffort::Max, + ] { + let cfg = make_config_for_validation(Provider::OpenAi, Some(effort)); + assert!( + cfg.validate().is_ok(), + "OpenAI must accept {effort:?} at startup (route-aware normalization at request build)" + ); + } } #[test] - fn validate_rejects_max_effort_for_databricks() { - // Databricks legacy uses OpenAI Chat wire format — same rejection. - let cfg = make_config_for_validation(Provider::Databricks, Some(ThinkingEffort::Max)); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("BUZZ_AGENT_THINKING_EFFORT=max"), "{err}"); - assert!(err.contains("not valid for OpenAI/Databricks"), "{err}"); + fn validate_accepts_all_efforts_for_databricks() { + // Legacy Databricks effort support is model-dependent and normalized at request build time. + for effort in [ + ThinkingEffort::None, + ThinkingEffort::Minimal, + ThinkingEffort::Low, + ThinkingEffort::Medium, + ThinkingEffort::High, + ThinkingEffort::XHigh, + ThinkingEffort::Max, + ] { + let cfg = make_config_for_validation(Provider::Databricks, Some(effort)); + assert!( + cfg.validate().is_ok(), + "Databricks must accept {effort:?} at startup (route-aware normalization at request build)" + ); + } } #[test] @@ -2069,6 +2201,26 @@ mod tests { ); } + #[test] + fn openai_efforts_for_model_gpt5_6_includes_max() { + let expected: &[ThinkingEffort] = &[ + ThinkingEffort::None, + ThinkingEffort::Low, + ThinkingEffort::Medium, + ThinkingEffort::High, + ThinkingEffort::XHigh, + ThinkingEffort::Max, + ]; + + for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5-6-sol", "goose-gpt-5-6-sol"] { + assert_eq!( + openai_efforts_for_model(model), + Some(expected), + "{model} must match the gpt-5.6 effort table" + ); + } + } + #[test] fn openai_efforts_for_model_gpt5_5_includes_xhigh() { let supported = openai_efforts_for_model("gpt-5.5").expect("gpt-5.5 must be in table"); @@ -2294,6 +2446,26 @@ mod tests { ); } + #[test] + fn normalize_openai_route_passes_max_through_for_gpt5_6() { + for model in ["gpt-5.6", "gpt-5-6-sol"] { + assert_eq!( + normalize_effort_for_openai_route(ThinkingEffort::Max, model), + ThinkingEffort::Max, + "{model} must preserve max" + ); + } + } + + #[test] + fn normalize_openai_route_gpt5_5_max_becomes_xhigh() { + assert_eq!( + normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.5"), + ThinkingEffort::XHigh, + "gpt-5.5 must clamp max to xhigh" + ); + } + #[test] fn normalize_openai_route_gpt5_5_minimal_becomes_none() { // gpt-5.5 supports none but not minimal. minimal → none (peer fallback). @@ -2366,4 +2538,164 @@ mod tests { ); } } + + // ---- effort-table fixture sync guard ---------------------------------------- + // + // Loads `effortTable.fixture.json` (the single source of truth shared with + // the TS test in `buzzAgentConfig.test.mjs`) and verifies that this Rust + // implementation produces the same valid-effort-value sets and default values + // as the TS `getProviderEffortConfig` function. + // + // Drift (a new model family added to one side but not the other) fails CI here + // before it can silently diverge in production. + // ───────────────────────────────────────────────────────────────────────────── + + /// Compute the valid effort values for a provider/model pair, mirroring + /// `getProviderEffortConfig` in `buzzAgentConfig.ts`. + /// + /// Returns `(valid_values, default_value)` where `default_value` is `None` + /// for Anthropic manual-budget models (TS `defaultValue: null`), otherwise + /// `Some("medium")` or `Some("high")`. + fn valid_effort_values_for_provider_model( + provider: &str, + model: &str, + ) -> (Vec<&'static str>, Option<&'static str>) { + const ALL_7: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + const ALL_EXCEPT_MAX: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh"]; + const GPT5_PRO: &[&str] = &["high"]; + const GPT5_1: &[&str] = &["none", "low", "medium", "high"]; + + let p = provider.to_ascii_lowercase(); + // Strip arbitrary endpoint-naming prefix before model matching, mirroring TS and + // strip_catalog_prefix: find the first known family token (claude-, gpt-) and + // drop everything before it. Handles any catalog naming convention. + let raw_model = model.trim(); + let lower_raw = raw_model.to_ascii_lowercase(); + const FAMILY_TOKENS: &[&str] = &["claude-", "gpt-"]; + let first_idx = FAMILY_TOKENS + .iter() + .filter_map(|tok| lower_raw.find(tok)) + .min(); + let stripped = match first_idx { + Some(idx) => &raw_model[idx..], + None => raw_model, + }; + let m = stripped.to_ascii_lowercase(); + + // Thin adapter: converts production helper output to the string-based + // return type used by this function. + fn anthropic_result(m: &str) -> (Vec<&'static str>, Option<&'static str>) { + let (values, default) = anthropic_efforts_for_model(m); + let strs: Vec<&'static str> = values.iter().map(|e| e.openai_effort_str()).collect(); + (strs, default.map(|e| e.openai_effort_str())) + } + + fn openai_result(m: &str) -> (Vec<&'static str>, Option<&'static str>) { + if let Some(values) = openai_efforts_for_model(m) { + let strs: Vec<&'static str> = + values.iter().map(|e| e.openai_effort_str()).collect(); + // Determine default from the family. + let default_val = if strs == GPT5_PRO { + Some("high") + } else if strs == GPT5_1 { + Some("none") + } else { + Some("medium") + }; + (strs, default_val) + } else { + // Unknown model → all-except-max, default medium. + (ALL_EXCEPT_MAX.to_vec(), Some("medium")) + } + } + + if p == "anthropic" { + return anthropic_result(&m); + } + if p == "openai" { + return openai_result(&m); + } + if p == "databricks_v2" { + if m.starts_with("claude-") { + return anthropic_result(&m); + } + // gpt-5 family check mirrors gpt5FamilyModel in TS. + let is_gpt5 = gpt5_token_matches(&m, "gpt-5-pro") + || gpt5_token_matches(&m, "gpt5-pro") + || gpt5_token_matches(&m, "gpt-5.6") + || gpt5_token_matches(&m, "gpt5.6") + || gpt5_token_matches(&m, "gpt-5-6") + || gpt5_token_matches(&m, "gpt5-6") + || gpt5_token_matches(&m, "gpt-5.5") + || gpt5_token_matches(&m, "gpt5.5") + || gpt5_token_matches(&m, "gpt-5.4") + || gpt5_token_matches(&m, "gpt5.4") + || gpt5_token_matches(&m, "gpt-5.1") + || gpt5_token_matches(&m, "gpt5.1") + || gpt5_base_matches(&m, "gpt-5") + || gpt5_base_matches(&m, "gpt5"); + if is_gpt5 { + return openai_result(&m); + } + if !m.is_empty() { + // Concrete non-claude, non-gpt5: MLflow path → all-except-max. + return openai_result(&m); + } + // Blank model: route unknown, all-7. + return (ALL_7.to_vec(), Some("medium")); + } + if p == "databricks" { + return openai_result(&m); + } + // openai-compat, unknown, empty → all-7, default medium. + (ALL_7.to_vec(), Some("medium")) + } + + #[derive(serde::Deserialize)] + struct FixtureEntry { + note: Option, + provider: String, + model: String, + #[serde(rename = "validValues")] + valid_values: Vec, + #[serde(rename = "defaultValue")] + default_value: Option, + } + + #[test] + fn effort_table_fixture_matches_rust_implementation() { + let fixture_json = + include_str!("../../../desktop/src/features/agents/ui/effortTable.fixture.json"); + let entries: Vec = + serde_json::from_str(fixture_json).expect("fixture must be valid JSON"); + + assert!( + !entries.is_empty(), + "fixture must contain at least one entry" + ); + + for entry in &entries { + let label = entry.note.as_deref().unwrap_or(entry.model.as_str()); + let (valid_values, default_value) = + valid_effort_values_for_provider_model(&entry.provider, &entry.model); + + let expected: Vec<&str> = entry.valid_values.iter().map(String::as_str).collect(); + assert_eq!( + valid_values, expected, + "validValues mismatch for fixture entry \"{label}\" \ + (provider={}, model={}): Rust side has {valid_values:?}, \ + fixture expects {expected:?}", + entry.provider, entry.model, + ); + + let expected_default: Option<&str> = entry.default_value.as_deref(); + assert_eq!( + default_value, expected_default, + "defaultValue mismatch for fixture entry \"{label}\" \ + (provider={}, model={}): Rust side has {default_value:?}, \ + fixture expects {expected_default:?}", + entry.provider, entry.model, + ); + } + } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index a279aa2e74..f5faafe853 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -15,6 +15,20 @@ pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MO pub use config::Provider; pub use types::AgentError; +/// Environment keys the Windows Git Bash resolver may inspect. `spawn_one()` +/// forwards every key in this list into its otherwise-cleared MCP child; Doctor +/// uses the same contract so a ready agent can always start its shell tool. +#[cfg(windows)] +pub const WINDOWS_SHELL_RESOLUTION_ENV: &[&str] = &[ + "PATH", + "BUZZ_SHELL", + "GIT_BASH", + "SystemRoot", + "ProgramFiles", + "ProgramFiles(x86)", + "LOCALAPPDATA", +]; + use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -728,6 +742,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender "contextLimit": 0u64, "accumulatedInputTokens": accumulated_in, "accumulatedOutputTokens": accumulated_out, + "model": effective_model_str, }), ), ) diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 76b39e8fe3..8a774fdf24 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -72,7 +72,7 @@ impl Llm { effective_model: &str, ) -> Result { let effort = cfg.thinking_effort; - match cfg.provider { + let result = match cfg.provider { Provider::Anthropic => { let v = self .post_anthropic( @@ -91,9 +91,10 @@ impl Llm { } Provider::OpenAi | Provider::Databricks => { self.openai_request(cfg, effective_model, |use_responses| { - // Normalize effort for model-specific availability (per-model table; max is - // already rejected at startup for pure OpenAI, but other per-model corrections - // like none→minimal on gpt-5 base still apply). + // Normalize effort for model-specific availability. Startup no longer rejects + // `max` for pure OpenAI/Databricks; this per-model table is the single authority + // — it keeps `max` for gpt-5.6, clamps `max`→`xhigh` for other OpenAI-shaped + // models, and still applies corrections like none→minimal on the gpt-5 base. let e = effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model)); if use_responses { ( @@ -112,7 +113,7 @@ impl Llm { Provider::DatabricksV2 => { self.databricks_v2_request(cfg, effective_model, |route| match route { DatabricksV2Route::OpenAiResponses => { - // OpenAI Responses path: normalize effort (max → xhigh, per-model table). + // OpenAI Responses path: normalize effort against the per-model table. let e = effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model)); ( @@ -129,7 +130,7 @@ impl Llm { ) } DatabricksV2Route::MlflowChatCompletions => { - // MLflow Chat path (OpenAI-shaped): normalize effort (max → xhigh, per-model table). + // MLflow Chat path (OpenAI-shaped): normalize effort against the per-model table. let e = effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model)); ( @@ -140,7 +141,20 @@ impl Llm { }) .await } - } + }; + // Stamp the effective model into Llm errors so log lines carry + // `llm: (model-name) 404 Not Found: …` instead of the bare status. + // The `llm: ` prefix comes from `Display for AgentError::Llm`; the + // map_err here prepends `(model-name) ` to the inner string only. + // This is the single place all provider paths converge, so the mapping + // is centralized and never needs to be repeated in each provider arm. + result.map_err(|e| match e { + AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")), + AgentError::LlmModelNotFound(s) => { + AgentError::LlmModelNotFound(format!("({effective_model}) {s}")) + } + other => other, + }) } pub async fn summarize( @@ -1061,6 +1075,12 @@ where backoff_with_jitter(attempt).await; continue; } + if status == 404 { + return Err(AgentError::LlmModelNotFound(format!( + "{status}: {}", + read_error_body(resp).await + ))); + } if !status.is_success() { return Err(AgentError::Llm(format!( "{status}: {}", @@ -1963,6 +1983,24 @@ mod tests { ); } + #[test] + fn dbv2_openai_route_max_effort_passes_through_for_gpt5_6() { + let normalized = + crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.6-sol"); + let body = responses_body( + &cfg_responses(), + "system", + &[HistoryItem::User("hi".into())], + &[], + "gpt-5.6-sol", + Some(normalized), + ); + assert_eq!( + body["reasoning"]["effort"], "max", + "DBv2 GPT-5.6 route must serialize max to the Responses API" + ); + } + #[test] fn dbv2_mlflow_route_max_effort_clamped_to_xhigh_in_openai_body() { // DBv2 MLflow route (unknown model): max → clamped to xhigh by normalize_effort_for_openai_route. diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 6a7b61dcac..71c3193a06 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -54,18 +54,31 @@ const PASSTHROUGH_ENV: &[&str] = &[ "GIT_CONFIG_GLOBAL", // Buzz identity — dev-mcp writes NOSTR_PRIVATE_KEY to a keyfile then // removes it from its own env (children never see it). BUZZ_PRIVATE_KEY - // and BUZZ_RELAY_URL are kept for the buzz CLI. + // and BUZZ_RELAY_URL are kept for the buzz CLI. BUZZ_AUTH_TAG is a + // non-secret signed ownership attestation needed by portable owner-scoped + // CLI operations; MCP subprocesses are trusted like the agent runtime. "NOSTR_PRIVATE_KEY", "BUZZ_PRIVATE_KEY", "BUZZ_RELAY_URL", + "BUZZ_AUTH_TAG", ]; // Windows has no $TMPDIR/$HOME. TMP/TEMP/USERPROFILE are what // std::env::temp_dir() consults — without them it falls back to C:\Windows, // which child processes can't write to (PermissionDenied). USERPROFILE is the -// always-set floor. LOCALAPPDATA/APPDATA carry child-tool config (git, etc.). +// always-set floor. APPDATA carries child-tool config (git, etc.). #[cfg(windows)] -const PASSTHROUGH_ENV_WINDOWS: &[&str] = &["TMP", "TEMP", "USERPROFILE", "LOCALAPPDATA", "APPDATA"]; +const PASSTHROUGH_ENV_WINDOWS: &[&str] = &["TMP", "TEMP", "USERPROFILE", "APPDATA"]; + +/// Environment retained by `spawn_one()` after `env_clear()` on Windows. +/// Shell resolver keys are shared with Doctor through the public contract. +#[cfg(windows)] +fn windows_child_passthrough_env() -> impl Iterator { + PASSTHROUGH_ENV_WINDOWS + .iter() + .copied() + .chain(crate::WINDOWS_SHELL_RESOLUTION_ENV.iter().copied()) +} type Client = RunningService; @@ -705,7 +718,7 @@ async fn spawn_one( } } #[cfg(windows)] - for k in PASSTHROUGH_ENV_WINDOWS { + for k in windows_child_passthrough_env() { if let Ok(v) = std::env::var(k) { cmd.env(k, v); } @@ -977,17 +990,28 @@ fn tool_result_content( #[cfg(test)] mod content_tests { use super::*; + + #[test] + fn passthrough_includes_buzz_owner_attestation() { + assert!(PASSTHROUGH_ENV.contains(&"BUZZ_AUTH_TAG")); + } use rmcp::model::Content; #[cfg(windows)] #[test] - fn windows_passthrough_includes_temp_dir_vars() { - // std::env::temp_dir() consults these in order; without them it falls - // back to C:\Windows, which children can't write to. + fn windows_passthrough_includes_shell_resolution_vars() { + // Temp directories and every resolver key must survive `env_clear()`. for var in ["TMP", "TEMP", "USERPROFILE"] { assert!( PASSTHROUGH_ENV_WINDOWS.contains(&var), - "{var} must pass through or temp_dir() falls back to C:\\Windows" + "{var} must pass through for Windows child processes" + ); + } + let child_env: Vec<_> = windows_child_passthrough_env().collect(); + for var in crate::WINDOWS_SHELL_RESOLUTION_ENV { + assert!( + child_env.contains(var), + "{var} must pass through so the MCP shell resolver matches Doctor" ); } } diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index ef006b70ac..d29e975e03 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -225,6 +225,7 @@ pub enum AgentError { InvalidParams(String), Llm(String), LlmAuth(String), + LlmModelNotFound(String), Mcp(String), Cancelled, } @@ -235,6 +236,7 @@ impl std::fmt::Display for AgentError { Self::InvalidParams(s) => write!(f, "invalid params: {s}"), Self::Llm(s) => write!(f, "llm: {s}"), Self::LlmAuth(s) => write!(f, "llm auth: {s}"), + Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"), Self::Mcp(s) => write!(f, "mcp: {s}"), Self::Cancelled => write!(f, "cancelled"), } @@ -248,6 +250,7 @@ impl AgentError { match self { Self::InvalidParams(_) => -32602, Self::LlmAuth(_) => -32001, + Self::LlmModelNotFound(_) => -32002, _ => -32000, } } diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 4f971b0ba8..aed9624d9d 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -35,7 +35,8 @@ pub use error::AuthError; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; pub use nip98_replay::{ - nip98_replay_key, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS, + nip98_replay_key, nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, + MAX_REPLAY_TTL_SECS, }; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, diff --git a/crates/buzz-auth/src/nip98_replay.rs b/crates/buzz-auth/src/nip98_replay.rs index aece4b0c7a..5923c0414d 100644 --- a/crates/buzz-auth/src/nip98_replay.rs +++ b/crates/buzz-auth/src/nip98_replay.rs @@ -61,6 +61,14 @@ pub const MAX_REPLAY_TTL_SECS: u64 = 3600; /// The production implementation lives in `buzz-pubsub` (Redis `SET NX EX`). /// A test impl is provided behind `cfg(any(test, feature = "test-utils"))`. pub trait Nip98ReplayGuard: Send + Sync { + /// Atomically claim `event_id` in an explicit deployment or community scope. + fn try_mark_in_scope<'a>( + &'a self, + scope: &'a str, + event_id: &'a EventId, + ttl_secs: u64, + ) -> Pin> + Send + 'a>>; + /// Atomically claim `event_id` for `ctx`'s community. /// /// Returns `Ok(true)` when the id is newly inserted (proceed) and @@ -89,7 +97,10 @@ pub trait Nip98ReplayGuard: Send + Sync { ctx: &'a TenantContext, event_id: &'a EventId, ttl_secs: u64, - ) -> Pin> + Send + 'a>>; + ) -> Pin> + Send + 'a>> { + let scope = ctx.community().to_string(); + Box::pin(async move { self.try_mark_in_scope(&scope, event_id, ttl_secs).await }) + } } /// Redis key for a NIP-98 replay marker: @@ -101,7 +112,12 @@ pub trait Nip98ReplayGuard: Send + Sync { /// isolation: a same-id replay across communities must consult two distinct /// seen-set rows, not one shared row. pub fn nip98_replay_key(ctx: &TenantContext, event_id: &EventId) -> String { - format!("buzz:{}:nip98:{}", ctx.community(), event_id.to_hex()) + nip98_replay_key_for_scope(&ctx.community().to_string(), event_id) +} + +/// Redis key for a NIP-98 replay marker in an explicit trusted scope. +pub fn nip98_replay_key_for_scope(scope: &str, event_id: &EventId) -> String { + format!("buzz:{scope}:nip98:{}", event_id.to_hex()) } /// Always-fresh seen-set for unit tests — every `try_mark` returns `Ok(true)`. @@ -112,9 +128,9 @@ pub struct AlwaysFreshReplayGuard; #[cfg(any(test, feature = "test-utils"))] impl Nip98ReplayGuard for AlwaysFreshReplayGuard { - fn try_mark<'a>( + fn try_mark_in_scope<'a>( &'a self, - _ctx: &'a TenantContext, + _scope: &'a str, _event_id: &'a EventId, _ttl_secs: u64, ) -> Pin> + Send + 'a>> { diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 3fa874ebd8..ecb3bb1450 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -38,6 +38,9 @@ nostr = { workspace = true } # UUID parsing for validate_uuid + event building uuid = { workspace = true } +# RFC3339 observer timestamps for owner-reviewed agent draft requests +chrono = { workspace = true } + # Typed event builders for all write operations buzz-sdk = { workspace = true } buzz-core = { workspace = true } @@ -54,6 +57,9 @@ diffy = "0.5" # Hex encoding — SHA-256 hash output for Blossom uploads hex = { workspace = true } +# Byte buffers returned by authenticated media downloads +bytes = "1" + # MIME type detection via magic bytes — file upload validation infer = "0.19" diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 675e00562f..304f656384 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -35,6 +35,7 @@ buzz messages send --channel --content - < message.md # read body from buzz messages get --channel --limit 20 buzz messages thread --channel --event buzz messages search --query "architecture" +buzz messages search --author --since buzz messages edit --event --content "Updated text" buzz messages delete --event @@ -95,7 +96,7 @@ buzz channels list | jq '.[].name' | | `delete` | Delete a message | | | `get` | List messages in a channel | | | `thread` | Get a message thread | -| | `search` | Full-text search | +| | `search` | Full-text search, filterable by author | | | `vote` | Vote on a forum post | | `channels` | `list` | List channels | | | `get` | Get channel details | diff --git a/crates/buzz-cli/src/agent_management.rs b/crates/buzz-cli/src/agent_management.rs new file mode 100644 index 0000000000..ce4059f821 --- /dev/null +++ b/crates/buzz-cli/src/agent_management.rs @@ -0,0 +1,277 @@ +//! Owner-reviewed agent draft requests published through Buzz observer frames. + +use buzz_core::observer::{encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY}; +use nostr::{Event, Keys, PublicKey}; +use serde::Serialize; + +use crate::error::CliError; + +const REQUEST_KIND: &str = "agent_management_request"; +const MAX_NAME_CHARS: usize = 120; +const MAX_PROMPT_CHARS: usize = 20_000; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAgentDraft { + pub channel_id: String, + pub display_name: String, + pub system_prompt: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAgentDraft { + pub channel_id: String, + pub agent_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub respond_to: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ManagementRequest { + #[serde(rename = "type")] + request_type: &'static str, + action: &'static str, + request_id: String, + request: T, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ObserverEvent { + seq: u64, + timestamp: String, + kind: &'static str, + agent_index: Option, + channel_id: Option, + session_id: Option, + turn_id: Option, + payload: ManagementRequest, +} + +#[derive(Debug)] +pub struct BuiltDraftRequest { + pub event: Event, + pub request_id: String, + pub action: &'static str, +} + +fn required(value: String, label: &str, max: usize) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(CliError::Usage(format!("{label} is required"))); + } + if value.chars().count() > max { + return Err(CliError::Usage(format!( + "{label} is too long (max {max} characters)" + ))); + } + Ok(value.to_owned()) +} + +fn optional(value: Option, label: &str) -> Result, CliError> { + value.map(|value| required(value, label, 300)).transpose() +} + +fn build( + keys: &Keys, + owner: &PublicKey, + channel_id: String, + action: &'static str, + request: T, +) -> Result { + let request_id = uuid::Uuid::new_v4().to_string(); + let payload = ObserverEvent { + seq: 0, + timestamp: chrono::Utc::now().to_rfc3339(), + kind: REQUEST_KIND, + agent_index: None, + channel_id: Some(channel_id), + session_id: None, + turn_id: None, + payload: ManagementRequest { + request_type: REQUEST_KIND, + action, + request_id: request_id.clone(), + request, + }, + }; + let encrypted = encrypt_observer_payload(keys, owner, &payload) + .map_err(|error| CliError::Other(format!("could not encrypt draft request: {error}")))?; + let event = buzz_sdk::build_agent_observer_frame( + &owner.to_hex(), + &keys.public_key().to_hex(), + OBSERVER_FRAME_TELEMETRY, + &encrypted, + ) + .map_err(|error| CliError::Other(format!("could not build draft request: {error}")))? + .sign_with_keys(keys) + .map_err(|error| CliError::Other(format!("could not sign draft request: {error}")))?; + Ok(BuiltDraftRequest { + event, + request_id, + action, + }) +} + +pub fn build_create( + keys: &Keys, + owner: &PublicKey, + draft: CreateAgentDraft, +) -> Result { + let channel_id = required(draft.channel_id, "channel", 128)?; + uuid::Uuid::parse_str(&channel_id) + .map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel_id}")))?; + let request = CreateAgentDraft { + channel_id: channel_id.clone(), + display_name: required(draft.display_name, "display name", MAX_NAME_CHARS)?, + system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?, + }; + build(keys, owner, channel_id, "create", request) +} + +pub fn build_update( + keys: &Keys, + owner: &PublicKey, + draft: UpdateAgentDraft, +) -> Result { + let channel_id = required(draft.channel_id, "channel", 128)?; + uuid::Uuid::parse_str(&channel_id) + .map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel_id}")))?; + let respond_to = optional(draft.respond_to, "respond-to")?; + if respond_to + .as_deref() + .is_some_and(|value| value != "owner-only" && value != "anyone") + { + return Err(CliError::Usage( + "respond-to must be owner-only or anyone".into(), + )); + } + let request = UpdateAgentDraft { + channel_id: channel_id.clone(), + agent_name: required(draft.agent_name, "agent name", MAX_NAME_CHARS)?, + display_name: optional(draft.display_name, "display name")?, + system_prompt: draft + .system_prompt + .map(|value| required(value, "system prompt", MAX_PROMPT_CHARS)) + .transpose()?, + runtime: optional(draft.runtime, "runtime")?, + provider: optional(draft.provider, "provider")?, + model: optional(draft.model, "model")?, + respond_to, + }; + if request.display_name.is_none() + && request.system_prompt.is_none() + && request.runtime.is_none() + && request.provider.is_none() + && request.model.is_none() + && request.respond_to.is_none() + { + return Err(CliError::Usage( + "include at least one field to update".into(), + )); + } + build(keys, owner, channel_id, "update", request) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::observer::{decrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_TAG}; + + const CHANNEL: &str = "7c07e659-3610-42f4-9a5e-1e9973c09da9"; + + #[test] + fn create_is_owner_encrypted_and_matches_desktop_contract() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let built = build_create( + &agent, + &owner.public_key(), + CreateAgentDraft { + channel_id: CHANNEL.into(), + display_name: "Research helper".into(), + system_prompt: "Find sources.".into(), + }, + ) + .unwrap(); + + assert_eq!(built.event.kind.as_u16(), 24_200); + let tags: Vec> = built + .event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + assert!(tags + .iter() + .any(|tag| tag == &["p", &owner.public_key().to_hex()])); + assert!(tags + .iter() + .any(|tag| tag == &[OBSERVER_AGENT_TAG, &agent.public_key().to_hex()])); + assert!(tags + .iter() + .any(|tag| tag == &[OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY])); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("h"))); + + let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap(); + assert_eq!(payload["kind"], REQUEST_KIND); + assert_eq!(payload["channelId"], CHANNEL); + assert_eq!(payload["payload"]["type"], REQUEST_KIND); + assert_eq!(payload["payload"]["action"], "create"); + assert_eq!( + payload["payload"]["request"]["displayName"], + "Research helper" + ); + assert!(payload["payload"]["request"].get("runtime").is_none()); + assert!(payload["payload"]["request"].get("respondTo").is_none()); + } + + #[test] + fn update_requires_a_change() { + let error = build_update( + &Keys::generate(), + &Keys::generate().public_key(), + UpdateAgentDraft { + channel_id: CHANNEL.into(), + agent_name: "Scout".into(), + display_name: None, + system_prompt: None, + runtime: None, + provider: None, + model: None, + respond_to: None, + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("at least one field")); + } + + #[test] + fn create_rejects_invalid_channel() { + let error = build_create( + &Keys::generate(), + &Keys::generate().public_key(), + CreateAgentDraft { + channel_id: "general".into(), + display_name: "Scout".into(), + system_prompt: "Help".into(), + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("invalid channel UUID")); + } +} diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 6e5eab1aa5..7c6f58b361 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -108,6 +108,212 @@ fn sign_nip98( Ok(format!("Nostr {}", B64.encode(json.as_bytes()))) } +fn relay_server_tag(relay_url: &str) -> Option { + let authority = buzz_core::tenant::relay_url_authority(relay_url); + if authority.is_empty() { + None + } else { + Some(authority) + } +} + +fn is_safe_media_path_segment(sha256_ext: &str) -> bool { + let segments: Vec<&str> = sha256_ext.split('.').collect(); + match segments.as_slice() { + [hash] => is_lower_hex_sha256(hash), + [hash, ext] => is_lower_hex_sha256(hash) && is_safe_media_ext(ext), + [hash, "thumb", "jpg"] => is_lower_hex_sha256(hash), + _ => false, + } +} + +fn is_lower_hex_sha256(value: &str) -> bool { + value.len() == 64 && value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) +} + +fn is_safe_media_ext(value: &str) -> bool { + !value.is_empty() + && value.len() <= 8 + && value.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9')) +} + +fn media_url_from_input(relay_url: &str, input: &str) -> Result { + let input = input.trim(); + if input.starts_with("http://") || input.starts_with("https://") { + let parsed = url::Url::parse(input) + .map_err(|e| CliError::Usage(format!("invalid media URL: {e}")))?; + if !parsed.path().starts_with("/media/") { + return Err(CliError::Usage( + "media URL must point at a /media/ path".to_string(), + )); + } + let Some(sha256_ext) = parsed.path().strip_prefix("/media/") else { + return Err(CliError::Usage( + "media URL must point at a /media/ path".to_string(), + )); + }; + if !is_safe_media_path_segment(sha256_ext) { + return Err(CliError::Usage( + "media path must be sha256, sha256.ext, or sha256.thumb.jpg".to_string(), + )); + } + let relay = url::Url::parse(relay_url) + .map_err(|e| CliError::Usage(format!("invalid relay URL: {e}")))?; + if parsed.scheme() != relay.scheme() + || parsed.host_str() != relay.host_str() + || parsed.port_or_known_default() != relay.port_or_known_default() + { + return Err(CliError::Usage( + "refusing to sign media GET for a non-relay origin".to_string(), + )); + } + return Ok(input.to_string()); + } + if input.contains("://") { + return Err(CliError::Usage( + "media URL must use http:// or https://".to_string(), + )); + } + + let sha256_ext = input.trim_start_matches("/media/"); + if sha256_ext.is_empty() { + return Err(CliError::Usage( + "media input must be a URL or sha256[.ext]".to_string(), + )); + } + if !is_safe_media_path_segment(sha256_ext) { + return Err(CliError::Usage( + "media input must be sha256, sha256.ext, or sha256.thumb.jpg".to_string(), + )); + } + Ok(format!( + "{}/media/{sha256_ext}", + relay_url.trim_end_matches('/') + )) +} + +fn sign_blossom_get(keys: &Keys, media_url: &str) -> Result { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use nostr::Timestamp; + + let now = Timestamp::now().as_secs(); + let exp_str = (now + 600).to_string(); + let domain = relay_server_tag(media_url) + .ok_or_else(|| CliError::Usage(format!("invalid media URL: {media_url}")))?; + let tags = vec![ + Tag::parse(["t", "get"]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?, + ]; + + let auth_event = EventBuilder::new(Kind::from(24242), "Get media") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + + Ok(format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) + )) +} + +#[cfg(test)] +mod media_download_tests { + use super::*; + + #[test] + fn media_url_from_sha_uses_relay_media_path() { + let hash = "a".repeat(64); + assert_eq!( + media_url_from_input("https://relay.example", &format!("{hash}.jpg")).unwrap(), + format!("https://relay.example/media/{hash}.jpg") + ); + assert_eq!( + media_url_from_input("https://relay.example/", &format!("/media/{hash}.jpg")).unwrap(), + format!("https://relay.example/media/{hash}.jpg") + ); + } + + #[test] + fn media_url_accepts_only_same_relay_media_urls() { + let hash = "a".repeat(64); + assert!(media_url_from_input( + "https://relay.example:443", + &format!("https://relay.example/media/{hash}.jpg") + ) + .is_ok()); + assert!(media_url_from_input( + "https://relay.example", + &format!("http://relay.example/media/{hash}.jpg") + ) + .is_err()); + assert!(media_url_from_input( + "https://relay.example", + &format!("https://evil.example/media/{hash}.jpg") + ) + .is_err()); + assert!(media_url_from_input( + "https://relay.example", + &format!("https://relay.example/media-evil/{hash}.jpg") + ) + .is_err()); + assert!(media_url_from_input( + "https://relay.example", + &format!("ftp://relay.example/media/{hash}.jpg") + ) + .is_err()); + } + + #[test] + fn media_url_rejects_path_confusion_and_non_hash_inputs() { + for input in [ + "abc123.jpg", + "../evil", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/evil.jpg", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.JPG", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.eviltoolong", + "https://relay.example/media/abc123.jpg", + "https://relay.example/media/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.JPG", + ] { + assert!( + media_url_from_input("https://relay.example", input).is_err(), + "input should be rejected: {input}" + ); + } + } + + #[test] + fn media_get_auth_header_is_server_scoped() { + let keys = Keys::generate(); + let hash = "a".repeat(64); + let header = sign_blossom_get( + &keys, + &format!("https://relay.example:443/media/{hash}.jpg"), + ) + .unwrap(); + let encoded = header.strip_prefix("Nostr ").unwrap(); + let json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .unwrap(); + let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); + event.verify().unwrap(); + assert_eq!(event.kind, Kind::from(24242)); + + let tags: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + assert!(tags.iter().any(|tag| tag.as_slice() == ["t", "get"])); + assert!(tags + .iter() + .any(|tag| tag.as_slice() == ["server", "relay.example"])); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("x"))); + } +} + pub struct BuzzClient { http: reqwest::Client, relay_url: String, // base URL, no trailing slash, e.g. "https://relay.buzz.place" @@ -245,6 +451,20 @@ impl BuzzClient { self.handle_response(resp).await } + /// GET an authed relay endpoint (NIP-98), returning the raw JSON body. + /// + /// `path` is a root-relative path incl. any query string, e.g. + /// `/moderation/reports?status=open&limit=20`. Used by the moderation + /// read commands, which read structured queue/audit rows rather than + /// stored events. + pub async fn get_authed(&self, path: &str) -> Result { + let url = format!("{}{path}", self.relay_url); + let auth = sign_nip98(&self.keys, "GET", &url, None)?; + let req = self.http.get(&url).header("Authorization", &auth); + let resp = self.with_auth_tag(req).send().await?; + self.handle_response(resp).await + } + /// Submit a signed Nostr event via POST /events. pub async fn submit_event(&self, event: nostr::Event) -> Result { let url = format!("{}/events", self.relay_url); @@ -344,16 +564,9 @@ impl BuzzClient { Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, ]; // Extract server domain from relay URL for BUD-11 server tag - if let Ok(parsed) = url::Url::parse(&self.relay_url) { - if let Some(host) = parsed.host_str() { - let domain = match parsed.port() { - Some(port) => format!("{host}:{port}"), - None => host.to_string(), - }; - blossom_tags.push( - Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?, - ); - } + if let Some(domain) = relay_server_tag(&self.relay_url) { + blossom_tags + .push(Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?); } let auth_event = EventBuilder::new(Kind::from(24242), "Upload file") @@ -395,6 +608,28 @@ impl BuzzClient { .map_err(|e| CliError::Other(format!("invalid upload response: {e}"))) } + /// Download a Blossom media blob using BUD-01 `t=get` auth. + pub async fn download_media(&self, input: &str) -> Result { + let url = media_url_from_input(&self.relay_url, input)?; + let auth_header = sign_blossom_get(&self.keys, &url)?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + // Do not forward Authorization or x-auth-tag to redirect targets. + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| CliError::Other(format!("http client init failed: {e}")))?; + let req = client.get(&url).header("Authorization", auth_header); + + let resp = self.with_auth_tag(req).send().await?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::Relay { status, body }); + } + + resp.bytes().await.map_err(CliError::Network) + } + async fn handle_response(&self, resp: reqwest::Response) -> Result { if !resp.status().is_success() { let status = resp.status().as_u16(); diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs new file mode 100644 index 0000000000..7947ae7e77 --- /dev/null +++ b/crates/buzz-cli/src/commands/agents.rs @@ -0,0 +1,72 @@ +use nostr::PublicKey; + +use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft}; +use crate::client::BuzzClient; +use crate::error::CliError; +use crate::validate::read_or_stdin; +use crate::{AgentsCmd, RespondToArg}; + +pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> { + let owner = client + .auth_tag_owner_hex() + .ok_or_else(|| CliError::Auth("agent draft requests require BUZZ_AUTH_TAG".into()))?; + let owner = PublicKey::parse(&owner) + .map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?; + + let built = match command { + AgentsCmd::DraftCreate { + channel, + display_name, + system_prompt, + } => build_create( + client.keys(), + &owner, + CreateAgentDraft { + channel_id: channel, + display_name, + system_prompt: read_or_stdin(&system_prompt)?, + }, + )?, + AgentsCmd::DraftUpdate { + channel, + agent_name, + display_name, + system_prompt, + runtime, + provider, + model, + respond_to, + } => build_update( + client.keys(), + &owner, + UpdateAgentDraft { + channel_id: channel, + agent_name, + display_name, + system_prompt: system_prompt + .map(|value| read_or_stdin(&value)) + .transpose()?, + runtime, + provider, + model, + respond_to: respond_to.map(RespondToArg::to_wire), + }, + )?, + }; + + let response = client.publish_ephemeral_event(built.event).await?; + let mut output: serde_json::Value = serde_json::from_str(&response) + .map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?; + if let Some(object) = output.as_object_mut() { + object.insert("request_id".into(), built.request_id.into()); + object.insert("action".into(), built.action.into()); + object.insert("saved".into(), false.into()); + object.insert( + "message".into(), + "Draft sent to Buzz Desktop for owner review. Nothing changes until the owner saves it." + .into(), + ); + } + println!("{output}"); + Ok(()) +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 714fcf5d67..290cc59fa8 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1,4 +1,4 @@ -use buzz_sdk::{DiffMeta, ThreadRef, VoteDirection}; +use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection}; use nostr::PublicKey; use uuid::Uuid; @@ -339,23 +339,138 @@ pub async fn cmd_get_thread( pub async fn cmd_search( client: &BuzzClient, - query: &str, + query: Option<&str>, + author: Option<&str>, + since: Option, limit: Option, format: &crate::OutputFormat, ) -> Result<(), CliError> { + if query.is_none() && author.is_none() { + return Err(CliError::Usage( + "at least one of --query or --author is required".into(), + )); + } let limit = limit.unwrap_or(20).min(100); - let filter = serde_json::json!({ + + let author_hex = match author { + Some(a) => Some(resolve_author(client, a).await?), + None => None, + }; + + let mut filter = serde_json::json!({ "kinds": [9, 40002, 45001, 45003], - "search": query, "limit": limit }); + if let Some(q) = query { + filter["search"] = serde_json::json!(q); + } + if let Some(ref pk) = author_hex { + filter["authors"] = serde_json::json!([pk]); + } + if let Some(s) = since { + filter["since"] = serde_json::json!(s); + } let resp = client.query(&filter).await?; - let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + // The full-text path returns relevance order; a pure author/time query has + // no relevance, so present newest-first like `messages get`. + if query.is_none() { + events.sort_by_key(|e| { + std::cmp::Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)) + }); + } let normalized = normalize_events(&events); println!("{}", format_events(&normalized, format)); Ok(()) } +/// Resolve an `--author` value to a 64-char hex pubkey. +/// +/// Accepts, in order of precedence: 64-char hex (validated), an `npub1…` +/// bech32 key, or a display name resolved via NIP-50 profile search. A name +/// must match exactly one user (case-insensitive, on `display_name` or +/// `name`) — ambiguity is an error listing the candidates rather than a +/// silent mix of authors. +async fn resolve_author(client: &BuzzClient, author: &str) -> Result { + let author = author.trim(); + if author.len() == 64 && author.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(author.to_ascii_lowercase()); + } + if author.starts_with("npub1") { + return nostr::PublicKey::parse(author) + .map(|pk| pk.to_hex()) + .map_err(|_| CliError::Usage(format!("invalid npub: {author}"))); + } + + // Display name → NIP-50 search on kind:0, exact case-insensitive match. + let filter = serde_json::json!({ + "kinds": [0], + "search": author, + "limit": 100 + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw).unwrap_or_default(); + let mut matches = match_profiles_by_name(&events, author); + match matches.len() { + 0 => Err(CliError::Usage(format!( + "no user found with name '{author}' — pass a hex pubkey or npub instead" + ))), + 1 => Ok(matches.remove(0).0), + _ => { + // Cap the candidate listing — some names are shared by dozens of + // users, and an unbounded list turns the error into a wall of text. + let shown = 5.min(matches.len()); + let mut listing: Vec = matches[..shown] + .iter() + .map(|(pk, name)| format!("{name} ({pk})")) + .collect(); + if matches.len() > shown { + listing.push(format!("… and {} more", matches.len() - shown)); + } + Err(CliError::Usage(format!( + "name '{author}' is ambiguous — matches: {}. Pass a pubkey instead", + listing.join(", ") + ))) + } + } +} + +/// Exact case-insensitive profile match on `display_name` or `name` across +/// kind:0 events. Returns deduped `(pubkey, shown name)` pairs. Pure so the +/// name-resolution semantics are unit-testable without a relay. +fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(String, String)> { + let lower = name.to_ascii_lowercase(); + let mut matches: Vec<(String, String)> = Vec::new(); + for e in events { + let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else { + continue; + }; + let Some(content) = e + .get("content") + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str::(s).ok()) + else { + continue; + }; + let display_name = content + .get("display_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let plain_name = content.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if display_name.to_ascii_lowercase() == lower || plain_name.to_ascii_lowercase() == lower { + let shown = if display_name.is_empty() { + plain_name + } else { + display_name + }; + matches.push((pubkey.to_string(), shown.to_string())); + } + } + matches.sort(); + matches.dedup(); + matches +} + pub struct SendMessageParams { pub channel_id: String, pub content: String, @@ -551,15 +666,29 @@ pub async fn cmd_send_diff_message(client: &BuzzClient, p: SendDiffParams) -> Re Ok(()) } -pub async fn cmd_delete_message(client: &BuzzClient, event_id: &str) -> Result<(), CliError> { +pub async fn cmd_delete_message( + client: &BuzzClient, + event_id: &str, + action_id: Option, + reason_code: Option<&str>, + public_reason: Option<&str>, +) -> Result<(), CliError> { validate_hex64(event_id)?; // Resolve channel_id from the event's h-tag let channel_uuid = resolve_channel_id(client, event_id).await?; let target_eid = parse_event_id(event_id)?; - let builder = buzz_sdk::build_delete_message(channel_uuid, target_eid) - .map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?; + let builder = buzz_sdk::build_delete_message_with_options( + channel_uuid, + target_eid, + DeleteMessageOptions { + action_id, + reason_code, + public_reason, + }, + ) + .map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?; let event = client.sign_event(builder)?; @@ -684,7 +813,21 @@ pub async fn dispatch( .await } MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await, - MessagesCmd::Delete { event } => cmd_delete_message(client, &event).await, + MessagesCmd::Delete { + event, + action_id, + reason_code, + public_reason, + } => { + cmd_delete_message( + client, + &event, + action_id, + reason_code.as_deref(), + public_reason.as_deref(), + ) + .await + } MessagesCmd::Get { channel, limit, @@ -709,7 +852,22 @@ pub async fn dispatch( limit, depth_limit, } => cmd_get_thread(client, &channel, &event, limit, depth_limit, format).await, - MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit, format).await, + MessagesCmd::Search { + query, + author, + since, + limit, + } => { + cmd_search( + client, + query.as_deref(), + author.as_deref(), + since, + limit, + format, + ) + .await + } MessagesCmd::Vote { event, direction } => { cmd_vote_on_post(client, &event, &direction).await } @@ -718,7 +876,7 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::{find_root_from_tags, parse_member_pubkeys}; + use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys}; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, }; @@ -944,4 +1102,66 @@ mod tests { }); assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_A]); } + + // ---- match_profiles_by_name (author resolution for `messages search --author`) ---- + + fn profile_event( + pubkey: &str, + display_name: Option<&str>, + name: Option<&str>, + ) -> serde_json::Value { + let mut content = serde_json::Map::new(); + if let Some(d) = display_name { + content.insert("display_name".into(), json!(d)); + } + if let Some(n) = name { + content.insert("name".into(), json!(n)); + } + json!({ + "pubkey": pubkey, + "content": serde_json::Value::Object(content).to_string(), + }) + } + + #[test] + fn author_name_match_is_exact_case_insensitive() { + let events = vec![ + profile_event(PK_VALID_A, Some("Aaron"), Some("aaron")), + // Substring only — NIP-50 may return it, but it must not match. + profile_event(PK_VALID_B, Some("Aaronson"), None), + ]; + let matches = match_profiles_by_name(&events, "aArOn"); + assert_eq!(matches, vec![(PK_VALID_A.to_string(), "Aaron".to_string())]); + } + + #[test] + fn author_name_ambiguity_returns_all_candidates() { + let events = vec![ + profile_event(PK_VALID_A, Some("Sam"), None), + profile_event(PK_VALID_B, None, Some("sam")), + ]; + let matches = match_profiles_by_name(&events, "sam"); + assert_eq!(matches.len(), 2); + } + + #[test] + fn author_name_no_match_and_malformed_content() { + let events = vec![ + profile_event(PK_VALID_A, Some("Aaron"), None), + json!({"pubkey": PK_VALID_B, "content": "not-json"}), + json!({"content": "{}"}), // missing pubkey + ]; + assert!(match_profiles_by_name(&events, "Zoe").is_empty()); + } + + #[test] + fn author_name_dedups_replaceable_event_copies() { + // Same (pubkey, name) appearing twice (e.g. duplicate kind:0 rows) + // must resolve unambiguously. + let events = vec![ + profile_event(PK_VALID_A, Some("Aaron"), None), + profile_event(PK_VALID_A, Some("Aaron"), None), + ]; + assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); + } } diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index cc62f9f193..6480874a07 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod agents; pub mod channels; pub mod dms; pub mod emoji; @@ -5,6 +6,7 @@ pub mod feed; pub mod issues; pub mod mem; pub mod messages; +pub mod moderation; pub mod notes; pub mod pack; pub mod patches; diff --git a/crates/buzz-cli/src/commands/moderation.rs b/crates/buzz-cli/src/commands/moderation.rs new file mode 100644 index 0000000000..c53aecaf85 --- /dev/null +++ b/crates/buzz-cli/src/commands/moderation.rs @@ -0,0 +1,165 @@ +//! `buzz moderation` — community moderation queue, enforcement, and audit. +//! +//! Mutations (`ban`/`unban`/`timeout`/`untimeout`/`resolve`) are signed +//! command events (kinds 9040–9044) submitted via `POST /events`, mirroring +//! the NIP-43 relay-admin 9030-series: the relay validates, authorizes +//! (owner/admin only), and executes them directly — they are never stored. +//! +//! Reads (`reports`/`restricted`/`audit`) hit dedicated mod-only, +//! NIP-98-authed relay endpoints under `/moderation/*`, because reports and +//! audit rows are structured queue rows, not public nostr events — serving +//! them over a REQ filter would mean synthesizing fake events and threading a +//! privileged authz check into the public read path. +//! +//! The community (tenant) is selected by the relay host — moderation commands +//! carry no channel scope. + +use nostr::Timestamp; + +use crate::client::{normalize_write_response, BuzzClient}; +use crate::error::CliError; +use crate::validate::validate_hex64; +use crate::{ModerationCmd, OutputFormat}; + +/// Resolve `--expires-in ` / `--expires-at ` into an absolute +/// unix-seconds expiry. At most one may be set (enforced by clap). +fn resolve_expiry(expires_in: Option, expires_at: Option) -> Option { + match (expires_in, expires_at) { + (Some(secs), _) => Some(Timestamp::now().as_secs() + secs), + (None, Some(ts)) => Some(ts), + (None, None) => None, + } +} + +async fn cmd_ban( + client: &BuzzClient, + pubkey: &str, + expires_in: Option, + expires_at: Option, + reason: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(pubkey)?; + let expiry = resolve_expiry(expires_in, expires_at); + let builder = buzz_sdk::build_moderation_ban(pubkey, expiry, reason) + .map_err(|e| CliError::Usage(format!("invalid ban: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_unban(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { + validate_hex64(pubkey)?; + let builder = buzz_sdk::build_moderation_unban(pubkey) + .map_err(|e| CliError::Usage(format!("invalid unban: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_timeout( + client: &BuzzClient, + pubkey: &str, + expires_in: Option, + expires_at: Option, + reason: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(pubkey)?; + let expiry = resolve_expiry(expires_in, expires_at) + .ok_or_else(|| CliError::Usage("timeout requires --expires-in or --expires-at".into()))?; + let builder = buzz_sdk::build_moderation_timeout(pubkey, expiry, reason) + .map_err(|e| CliError::Usage(format!("invalid timeout: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_untimeout(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { + validate_hex64(pubkey)?; + let builder = buzz_sdk::build_moderation_untimeout(pubkey) + .map_err(|e| CliError::Usage(format!("invalid untimeout: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_resolve( + client: &BuzzClient, + report: &str, + status: &str, + action: &str, + reason: Option<&str>, +) -> Result<(), CliError> { + validate_hex64(report)?; + let builder = buzz_sdk::build_moderation_resolve_report(report, status, action, reason) + .map_err(|e| CliError::Usage(format!("invalid resolution: {e}")))?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +async fn cmd_reports( + client: &BuzzClient, + status: Option<&str>, + limit: i64, +) -> Result<(), CliError> { + let mut path = format!("/moderation/reports?limit={limit}"); + if let Some(s) = status { + path.push_str(&format!("&status={s}")); + } + let resp = client.get_authed(&path).await?; + println!("{resp}"); + Ok(()) +} + +async fn cmd_restricted(client: &BuzzClient) -> Result<(), CliError> { + let resp = client.get_authed("/moderation/restricted").await?; + println!("{resp}"); + Ok(()) +} + +async fn cmd_audit(client: &BuzzClient, limit: i64) -> Result<(), CliError> { + let resp = client + .get_authed(&format!("/moderation/audit?limit={limit}")) + .await?; + println!("{resp}"); + Ok(()) +} + +pub async fn dispatch( + cmd: ModerationCmd, + client: &BuzzClient, + _format: &OutputFormat, +) -> Result<(), CliError> { + match cmd { + ModerationCmd::Reports { status, limit } => { + cmd_reports(client, status.as_deref(), limit).await + } + ModerationCmd::Resolve { + report, + status, + action, + reason, + } => cmd_resolve(client, &report, &status, &action, reason.as_deref()).await, + ModerationCmd::Ban { + pubkey, + expires_in, + expires_at, + reason, + } => cmd_ban(client, &pubkey, expires_in, expires_at, reason.as_deref()).await, + ModerationCmd::Unban { pubkey } => cmd_unban(client, &pubkey).await, + ModerationCmd::Timeout { + pubkey, + expires_in, + expires_at, + reason, + } => cmd_timeout(client, &pubkey, expires_in, expires_at, reason.as_deref()).await, + ModerationCmd::Untimeout { pubkey } => cmd_untimeout(client, &pubkey).await, + ModerationCmd::Restricted => cmd_restricted(client).await, + ModerationCmd::Audit { limit } => cmd_audit(client, limit).await, + } +} diff --git a/crates/buzz-cli/src/commands/upload.rs b/crates/buzz-cli/src/commands/upload.rs index ed6e5addb5..d7543cb71a 100644 --- a/crates/buzz-cli/src/commands/upload.rs +++ b/crates/buzz-cli/src/commands/upload.rs @@ -13,3 +13,24 @@ pub async fn dispatch(cmd: crate::UploadCmd, client: &BuzzClient) -> Result<(), } } } + +pub async fn dispatch_media(cmd: crate::MediaCmd, client: &BuzzClient) -> Result<(), CliError> { + match cmd { + crate::MediaCmd::Get { input, output } => { + let bytes = client.download_media(&input).await?; + match output.as_deref() { + Some(path) if path != "-" => { + std::fs::write(path, &bytes) + .map_err(|e| CliError::Other(format!("could not write {path}: {e}")))?; + } + _ => { + use std::io::Write; + std::io::stdout() + .write_all(&bytes) + .map_err(|e| CliError::Other(format!("could not write stdout: {e}")))?; + } + } + Ok(()) + } + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 191cc73d9b..635a89424f 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agent_management; mod client; mod commands; mod error; @@ -7,6 +8,7 @@ use clap::{Parser, Subcommand}; use client::BuzzClient; use error::CliError; use nostr::Keys; +use uuid::Uuid; /// Run the Buzz CLI from raw arguments (including `argv[0]`). /// @@ -159,6 +161,9 @@ pub enum OutputFormat { #[derive(Subcommand)] enum Cmd { + /// Draft owner-reviewed agent creation and updates + #[command(subcommand)] + Agents(AgentsCmd), /// Send, read, search, and manage messages #[command(subcommand)] Messages(MessagesCmd), @@ -204,6 +209,9 @@ enum Cmd { /// Open, update, list, and set status on git pull requests (NIP-34) #[command(subcommand)] Pr(PrCmd), + /// Upload and download relay Blossom media + #[command(subcommand)] + Media(MediaCmd), /// Upload files to the relay's Blossom store #[command(subcommand)] Upload(UploadCmd), @@ -213,6 +221,65 @@ enum Cmd { /// Persona pack operations (local, no relay connection needed) #[command(subcommand)] Pack(PackCmd), + /// Community moderation — reports queue, bans, timeouts, audit trail + #[command(subcommand)] + Moderation(ModerationCmd), +} + +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum RespondToArg { + #[value(name = "owner-only")] + OwnerOnly, + #[value(name = "anyone")] + Anyone, +} + +impl RespondToArg { + fn to_wire(self) -> String { + match self { + Self::OwnerOnly => "owner-only", + Self::Anyone => "anyone", + } + .to_string() + } +} + +#[derive(Subcommand)] +pub enum AgentsCmd { + /// Open a prefilled create-agent form in the owner's Buzz Desktop + DraftCreate { + /// Current channel UUID; the new agent is added here after save + #[arg(long)] + channel: String, + /// Proposed agent name + #[arg(long)] + display_name: String, + /// Proposed instructions; use '-' to read from stdin + #[arg(long)] + system_prompt: String, + }, + /// Open a prefilled edit-agent form in the owner's Buzz Desktop + DraftUpdate { + /// Current channel UUID + #[arg(long)] + channel: String, + /// Current name of the personal agent to update + #[arg(long)] + agent_name: String, + #[arg(long)] + display_name: Option, + /// Replacement instructions; use '-' to read from stdin + #[arg(long)] + system_prompt: Option, + #[arg(long)] + runtime: Option, + #[arg(long)] + provider: Option, + #[arg(long)] + model: Option, + #[arg(long, value_enum)] + respond_to: Option, + }, } #[derive(Subcommand)] @@ -294,6 +361,15 @@ pub enum MessagesCmd { /// Event ID to delete (64-char hex) #[arg(long)] event: String, + /// Optional moderation audit action UUID for the public tombstone + #[arg(long)] + action_id: Option, + /// Optional machine-readable public reason code for the tombstone + #[arg(long)] + reason_code: Option, + /// Optional human-readable public reason for the tombstone + #[arg(long)] + public_reason: Option, }, /// Retrieve messages from a channel #[command( @@ -332,10 +408,19 @@ pub enum MessagesCmd { depth_limit: Option, }, /// Full-text search across messages + #[command( + after_help = "Examples:\n buzz messages search --query checkout\n buzz messages search --author npub1... --since 1783497600\n buzz messages search --author Aaron --query checkout --limit 20" + )] Search { - /// Search query string + /// Search query string (optional when --author is given) #[arg(long)] - query: String, + query: Option, + /// Filter by author: 64-char hex pubkey, npub, or display name + #[arg(long)] + author: Option, + /// Unix timestamp — return messages after this time + #[arg(long)] + since: Option, /// Maximum number of results to return #[arg(long)] limit: Option, @@ -1310,6 +1395,18 @@ pub enum UploadCmd { }, } +#[derive(Subcommand)] +pub enum MediaCmd { + /// Download relay media with Blossom get auth + Get { + /// Relay media URL or sha256[.ext] path segment + input: String, + /// Output path. Omit or use '-' to write raw bytes to stdout. + #[arg(short, long)] + output: Option, + }, +} + /// Subcommands for `buzz mem`. #[derive(Subcommand)] pub enum MemCmd { @@ -1408,6 +1505,102 @@ pub enum PackCmd { }, } +/// Community moderation commands. +/// +/// The community (tenant) is selected by the relay host in `--relay` / +/// `BUZZ_RELAY_URL` — moderation commands are community-global and carry no +/// channel scope. The signing key must be a community owner/admin; the relay +/// authorizes every command. +#[derive(Subcommand)] +pub enum ModerationCmd { + /// List reports in the moderation queue (newest first) + #[command( + after_help = "Examples:\n buzz moderation reports\n buzz moderation reports --status open --limit 20" + )] + Reports { + /// Filter by status: open | resolved | dismissed | escalated (default: all) + #[arg(long)] + status: Option, + /// Maximum number of reports to return + #[arg(long, default_value_t = 50)] + limit: i64, + }, + /// Resolve or dismiss a report (kind 9044) + #[command( + after_help = "Examples:\n buzz moderation resolve --report --status dismissed --action dismiss\n buzz moderation resolve --report --status resolved --action ban --reason \"rule 3\"" + )] + Resolve { + /// Hex event id of the kind:1984 report being resolved + #[arg(long)] + report: String, + /// Resolution status: resolved | dismissed + #[arg(long)] + status: String, + /// Action taken: delete | kick | ban | timeout | dismiss | escalate + #[arg(long)] + action: String, + /// Optional reason — relayed to the reporter, so keep it tombstone-safe + #[arg(long)] + reason: Option, + }, + /// Ban a member from the community (kind 9040) + #[command( + after_help = "Examples:\n buzz moderation ban --pubkey \n buzz moderation ban --pubkey --expires-in 604800 --reason \"repeated spam\"" + )] + Ban { + /// Target member pubkey (hex) + #[arg(long)] + pubkey: String, + /// Ban duration in seconds from now (omit for a permanent ban) + #[arg(long, conflicts_with = "expires_at")] + expires_in: Option, + /// Absolute ban expiry as a unix timestamp (seconds) + #[arg(long)] + expires_at: Option, + /// Optional private ban reason (audit only) + #[arg(long)] + reason: Option, + }, + /// Lift a member's ban (kind 9041) + Unban { + /// Target member pubkey (hex) + #[arg(long)] + pubkey: String, + }, + /// Time out a member — a write-block, not a disconnect (kind 9042) + #[command( + after_help = "Examples:\n buzz moderation timeout --pubkey --expires-in 3600\n buzz moderation timeout --pubkey --expires-at 1783500000 --reason \"cool off\"" + )] + Timeout { + /// Target member pubkey (hex) + #[arg(long)] + pubkey: String, + /// Timeout duration in seconds from now + #[arg(long, conflicts_with = "expires_at")] + expires_in: Option, + /// Absolute timeout expiry as a unix timestamp (seconds) + #[arg(long)] + expires_at: Option, + /// Optional private timeout reason (audit only) + #[arg(long)] + reason: Option, + }, + /// Clear a member's timeout early (kind 9043) + Untimeout { + /// Target member pubkey (hex) + #[arg(long)] + pubkey: String, + }, + /// List currently-restricted members (active ban or timeout) + Restricted, + /// Read the moderation audit trail (newest first) + Audit { + /// Maximum number of audit rows to return + #[arg(long, default_value_t = 50)] + limit: i64, + }, +} + async fn run(cli: Cli) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); @@ -1446,6 +1639,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?; match cli.command { + Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, @@ -1461,8 +1655,10 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await, Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await, Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await, + Cmd::Media(sub) => commands::upload::dispatch_media(sub, &client).await, Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await, Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await, + Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await, Cmd::Pack(_) => unreachable!("handled above"), } } @@ -1481,14 +1677,17 @@ mod tests { #[test] fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ + "agents", "canvas", "channels", "dms", "emoji", "feed", "issues", + "media", "mem", "messages", + "moderation", "notes", "pack", "patches", @@ -1540,6 +1739,7 @@ mod tests { } let cmd = Cli::command(); + assert_eq!(names(&cmd, "agents"), vec!["draft-create", "draft-update"]); assert_eq!( names(&cmd, "messages"), vec![ @@ -1618,19 +1818,35 @@ mod tests { names(&cmd, "issues"), vec!["create", "get", "list", "status"] ); + assert_eq!(names(&cmd, "media"), vec!["get"]); assert_eq!(names(&cmd, "upload"), vec!["file"]); assert_eq!(names(&cmd, "pack"), vec!["inspect", "validate"]); + assert_eq!( + names(&cmd, "moderation"), + vec![ + "audit", + "ban", + "reports", + "resolve", + "restricted", + "timeout", + "unban", + "untimeout" + ] + ); } #[test] fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ + ("agents", 2), ("canvas", 2), ("channels", 16), ("dms", 4), ("emoji", 5), ("feed", 1), ("issues", 4), + ("media", 1), ("messages", 8), ("pack", 2), ("patches", 4), diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index ae9e7ef13f..3d671129e6 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -77,6 +77,8 @@ pub const KIND_READ_STATE: u32 = 30078; pub const KIND_AUTH: u32 = 22242; /// BUD-01: Blossom upload auth (used in upload.rs, not stored). pub const KIND_BLOSSOM_AUTH: u32 = 24242; +/// Buzz custom one-time identity binding proof (ephemeral, not stored). +pub const KIND_NOSTR_IDENTITY_BINDING: u32 = 24243; /// NIP-98: HTTP auth event (used in nip98.rs, not stored). pub const KIND_HTTP_AUTH: u32 = 27235; @@ -99,6 +101,13 @@ pub const KIND_AGENT_ENGRAM: u32 = 30174; /// author-only (see [`AUTHOR_ONLY_KINDS`]). See `docs/nips/NIP-ER.md`. pub const KIND_EVENT_REMINDER: u32 = 30300; +/// NIP-PL: encrypted push lease (parameterized replaceable, author-only). +/// +/// The source event contains endpoint-bearing NIP-44 ciphertext and is readable +/// only by its authenticated author. Effective delivery state lives in the +/// dedicated push lease tables. +pub const KIND_PUSH_LEASE: u32 = 30350; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -106,9 +115,9 @@ pub const KIND_EVENT_REMINDER: u32 = 30300; /// Shared across the ingest write path (NIP-ER `not_before` validation) and the /// read path (REQ/COUNT/subscription author-only filtering). /// -/// Currently O(1) with a single entry. If this grows past ~4 kinds, convert to -/// a compile-time bitset or sorted array with binary search for hot-path use. -pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER]; +/// Currently a tiny linear set. If this grows past ~4 kinds, convert to a +/// compile-time bitset or sorted array with binary search for hot-path use. +pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER, KIND_PUSH_LEASE]; /// Kinds that require a result-level read gate beyond the filter-layer /// `#p` check: even a reader who knows an event id MUST match the event's @@ -173,6 +182,18 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +// NIP-56 reporting +/// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). +/// +/// Accepted at ingest, persisted to the tenant-scoped `moderation_reports` +/// queue, and never fanned out publicly. Reports are signals, not triggers: +/// the relay never auto-actions on them (NIP-56). +pub const KIND_REPORT: u32 = 1984; + +/// Buzz product feedback submission. Accepted at ingest, sidecarred to the +/// deployment feedback table, and never stored or fanned out as an event. +pub const KIND_PRODUCT_FEEDBACK: u32 = 42000; + // NIP-29 group admin events /// NIP-29: Add a user to a group. pub const KIND_NIP29_PUT_USER: u32 = 9000; @@ -193,6 +214,40 @@ pub const KIND_NIP29_JOIN_REQUEST: u32 = 9021; /// NIP-29: Request to leave a group. pub const KIND_NIP29_LEAVE_REQUEST: u32 = 9022; +// Buzz community moderation commands (mod-signed, processed like 9030-series: +// validated + executed directly, never stored as regular events; every +// accepted command writes a `moderation_actions` audit row). +/// Moderation: ban a pubkey from the community (`p` tag target, optional +/// `expiration` + `reason` tags). +pub const KIND_MODERATION_BAN: u32 = 9040; +/// Moderation: lift a community ban (`p` tag target). +pub const KIND_MODERATION_UNBAN: u32 = 9041; +/// Moderation: timeout (write-block) a pubkey until an `expiration` tag +/// timestamp (`p` tag target, optional `reason`). +pub const KIND_MODERATION_TIMEOUT: u32 = 9042; +/// Moderation: clear a timeout early (`p` tag target). +pub const KIND_MODERATION_UNTIMEOUT: u32 = 9043; +/// Moderation: resolve a report (`report` tag = report event id hex, +/// `status` tag = resolved|dismissed, `action` tag = +/// delete|kick|ban|timeout|dismiss|escalate — see +/// `handlers/moderation_commands.rs` for the pinned vocabulary). +pub const KIND_MODERATION_RESOLVE_REPORT: u32 = 9044; + +/// Returns `true` for community moderation command kinds (9040–9044). +/// +/// The canonical route check — use this instead of scattering +/// `9040..=9044` matches across ingest/dispatch. +pub const fn is_moderation_command_kind(kind: u32) -> bool { + matches!( + kind, + KIND_MODERATION_BAN + | KIND_MODERATION_UNBAN + | KIND_MODERATION_TIMEOUT + | KIND_MODERATION_UNTIMEOUT + | KIND_MODERATION_RESOLVE_REPORT + ) +} + // NIP-43 relay membership admin commands /// NIP-43: Add a pubkey to the relay member list. pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030; @@ -250,12 +305,6 @@ pub const KIND_WINDOW_BOUNDS: u32 = 39006; /// Workflow definition (parameterized replaceable, d=workflow_uuid). pub const KIND_WORKFLOW_DEF: u32 = 30620; -/// Mesh-LLM relay status (relay-signed, parameterized replaceable, d=buzz-relay-mesh). -/// -/// Published only by the relay. Carries a sanitized, member-readable projection -/// of mesh status, including EndpointAddr dial pointers for serving nodes. -pub const KIND_MESH_LLM_RELAY_STATUS: u32 = 30621; - /// NIP-DV: per-viewer DM visibility snapshot (relay-signed, parameterized /// replaceable, d=viewer_pubkey). Carries one `h` tag per DM the viewer has /// hidden from their sidebar. Re-published by the relay on every hide/unhide so @@ -285,23 +334,6 @@ pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200; /// Ephemeral: huddle emoji reaction burst. Channel-scoped to the ephemeral /// huddle channel with an `h` tag; never stored in the timeline. pub const KIND_HUDDLE_REACTION: u32 = 24810; -/// Ephemeral: mesh status report (desktop → relay). A relay member reports its -/// current mesh serve availability + EndpointAddr(s) so the relay can project a -/// sanitized, relay-signed kind:30621 discovery note keyed per reporter. Tagged -/// `["p", ]` optional; never stored — the durable record is the relay's -/// 30621, not this transient input. -pub const KIND_MESH_STATUS_REPORT: u32 = 24620; -/// Ephemeral: mesh connect request (desktop → relay). A relay member asks the -/// relay to coordinate a direct iroh hole-punch to a peer it discovered via -/// kind:30621. Tagged `["p", ]`. Never stored; the relay -/// validates membership of both ends, then emits paired KIND_MESH_CALL_ME_NOW. -pub const KIND_MESH_CONNECT_REQUEST: u32 = 24621; -/// Ephemeral: mesh call-me-now signal (relay → desktop, relay-signed). The live -/// dial trigger for a direct iroh hole-punch — carries the peer's EndpointAddr -/// so both ends dial near-simultaneously. Tagged `["p", ]`. -/// Never stored; seconds expiry. -pub const KIND_MESH_CALL_ME_NOW: u32 = 24622; - // Stream messaging /// NIP-29 group chat message kind. V1 used kind:10001 (replaceable range — wrong), then 40001. /// @@ -478,6 +510,8 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_REPORT, + KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP29_EDIT_METADATA, @@ -487,6 +521,11 @@ pub const ALL_KINDS: &[u32] = &[ KIND_NIP29_CREATE_INVITE, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, + KIND_MODERATION_BAN, + KIND_MODERATION_UNBAN, + KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNTIMEOUT, + KIND_MODERATION_RESOLVE_REPORT, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_CHANGE_ROLE, @@ -509,9 +548,6 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PRESENCE_UPDATE, KIND_TYPING_INDICATOR, KIND_HUDDLE_REACTION, - KIND_MESH_STATUS_REPORT, - KIND_MESH_CONNECT_REQUEST, - KIND_MESH_CALL_ME_NOW, KIND_BLOSSOM_AUTH, KIND_PAIRING, KIND_AGENT_OBSERVER_FRAME, @@ -528,7 +564,6 @@ pub const ALL_KINDS: &[u32] = &[ KIND_SYSTEM_MESSAGE, KIND_CHANNEL_SUMMARY, KIND_PRESENCE_SNAPSHOT, - KIND_MESH_LLM_RELAY_STATUS, KIND_DM_VISIBILITY, KIND_DM_OPEN, KIND_DM_ADD_MEMBER, @@ -649,7 +684,6 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { kind, KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT - | KIND_MESH_LLM_RELAY_STATUS | KIND_DM_VISIBILITY | KIND_THREAD_SUMMARY | KIND_WINDOW_BOUNDS @@ -675,7 +709,6 @@ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 300 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 -const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 @@ -700,6 +733,15 @@ const _: () = assert!(!is_ephemeral(KIND_AGENT_TURN_METRIC)); const _: () = assert!(!is_replaceable(KIND_AGENT_TURN_METRIC)); const _: () = assert!(!is_parameterized_replaceable(KIND_AGENT_TURN_METRIC)); const _: () = assert!(KIND_AGENT_TURN_METRIC <= u16::MAX as u32); +// Moderation kinds fit u16 and are neither replaceable nor ephemeral: +// 1984 is a regular event (persisted to the queue, never fanned out); +// 9040–9044 are direct commands (executed, never stored). +const _: () = assert!(KIND_REPORT <= u16::MAX as u32); +const _: () = assert!(KIND_MODERATION_RESOLVE_REPORT <= u16::MAX as u32); +const _: () = assert!(!is_ephemeral(KIND_REPORT)); +const _: () = assert!(is_moderation_command_kind(KIND_MODERATION_BAN)); +const _: () = assert!(is_moderation_command_kind(KIND_MODERATION_RESOLVE_REPORT)); +const _: () = assert!(!is_moderation_command_kind(KIND_REPORT)); #[cfg(test)] mod tests { diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 5c38e5d1c5..c522d27bc2 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -613,7 +613,6 @@ pub async fn get_accessible_channel_ids( SELECT id AS channel_id FROM channels WHERE community_id = $1 AND visibility = 'open' AND deleted_at IS NULL - LIMIT 1000 "#, ) .bind(community_id.as_uuid()) @@ -1346,6 +1345,7 @@ pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result, +) -> i64 { + let mut hash: u64 = 0xcbf29ce484222325; + let kind_bytes = kind.to_le_bytes(); + for bytes in [ + community_id.as_uuid().as_bytes().as_slice(), + kind_bytes.as_slice(), + pubkey, + ] { + for byte in bytes { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + } + if let Some(coordinate) = coordinate { + for byte in coordinate { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + } + hash as i64 +} + /// Extract p-tag mentions from an event and insert into the `event_mentions` table. /// /// Called after event insertion. Failures are logged but do not block event storage. @@ -146,10 +181,31 @@ pub struct DbPoolStats { pub max: u32, } +/// Owns the detached Postgres session holding the relay usage-metrics advisory lock. +/// +/// The connection deliberately does not return to the main pool: session advisory +/// locks must remain bound to this exact physical connection, and the poller +/// pings it before each leader-only collection tick. +pub struct UsageMetricsLeader { + connection: PgConnection, +} + +impl UsageMetricsLeader { + /// Returns whether the lock-owning session is still reachable. + /// + /// Bounded to 5 seconds — a blackholed connection (no RST) would otherwise + /// stall the entire poller tick until the OS TCP timeout. + pub async fn is_live(&mut self) -> bool { + tokio::time::timeout(std::time::Duration::from_secs(5), self.connection.ping()) + .await + .is_ok_and(|r| r.is_ok()) + } +} + /// Configuration for the Postgres connection pool. #[derive(Debug, Clone)] pub struct DbConfig { - /// Postgres connection URL (e.g. `postgres://user:pass@host/db`). + /// Postgres connection URL (usually sourced from `DATABASE_URL`). pub database_url: String, /// Maximum number of connections in the pool. pub max_connections: u32, @@ -169,7 +225,7 @@ impl Default for DbConfig { /// At 20 main + 5 audit = 25/pod, four relay pods fit within the PG limit. fn default() -> Self { Self { - database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), + database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), // sadscan:disable np.postgres.1 max_connections: 20, min_connections: 2, acquire_timeout_secs: 3, @@ -188,6 +244,70 @@ pub struct CommunityRecord { pub host: String, } +/// Community row returned by idempotent community ensure/create operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnsuredCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, + /// True only when this call inserted the `communities` row. + pub created: bool, +} + +/// Community row returned by an atomic create-with-owner operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreatedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host stored for the community. + pub host: String, +} + +/// Result of atomically creating a community with its initial owner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreateCommunityWithOwnerResult { + /// The community was created, or an identical retried create found it. + Created(CreatedCommunityRecord), + /// The host already belongs to another owner. + HostExists, + /// The intended owner already owns the maximum number of communities. + LimitReached, +} + +/// Community row returned by operator-plane ownership reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, + /// When the community row was created. + pub created_at: DateTime, + /// When the community was archived; absent while active. + pub archived_at: Option>, +} + +/// Community row returned by an owner-authorized archive operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArchivedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Reserved canonical host. + pub host: String, + /// Durable first-archive timestamp. + pub archived_at: DateTime, +} + +/// Community row returned by an owner-authorized unarchive operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnarchivedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Reserved canonical host restored to active admission. + pub host: String, +} + /// Token summary returned by [`Db::list_active_tokens`]. #[derive(Debug, Clone)] pub struct TokenSummary { @@ -253,6 +373,88 @@ impl Db { } } + /// Try to acquire the detached session advisory lock for relay usage metrics. + /// + /// The returned guard owns the exact connection that acquired the lock. It is + /// detached from the shared pool so a stable leader neither returns a locked + /// session to other callers nor permanently consumes a pool slot. Dropping the + /// guard closes the connection and releases the session-scoped lock. + pub async fn try_lock_usage_metrics( + &self, + lock_key: i64, + ) -> Result> { + let mut connection = self.pool.acquire().await?; + let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") + .bind(lock_key) + .fetch_one(&mut *connection) + .await?; + if acquired { + Ok(Some(UsageMetricsLeader { + connection: connection.detach(), + })) + } else { + Ok(None) + } + } + + /// Return total number of communities on this relay. + pub async fn usage_community_count(&self) -> Result { + usage::community_count(&self.pool).await + } + + /// Return per-community user counts split by human/agent. + pub async fn usage_user_counts(&self) -> Result> { + usage::user_counts(&self.pool).await + } + + /// Return per-community channel counts by type. + pub async fn usage_channel_counts(&self) -> Result> { + usage::channel_counts(&self.pool).await + } + + /// Return per-community kind=9 message counts. + pub async fn usage_message_counts(&self) -> Result> { + usage::message_counts(&self.pool).await + } + + /// Return per-community relay-member counts by role. + pub async fn usage_relay_member_counts(&self) -> Result> { + usage::relay_member_counts(&self.pool).await + } + + /// Return per-community workflow counts by status. + pub async fn usage_workflow_counts(&self) -> Result> { + usage::workflow_counts(&self.pool).await + } + + /// Return per-community git-repo counts. + pub async fn usage_git_repo_counts(&self) -> Result> { + usage::git_repo_counts(&self.pool).await + } + + /// Return per-community distinct active-user counts for a given SQL interval. + /// + /// `interval_sql` must be a trusted literal such as `"1 day"` or `"7 days"`. + pub async fn usage_active_user_counts( + &self, + interval_sql: &'static str, + ) -> Result> { + usage::active_user_counts(&self.pool, interval_sql).await + } + + /// Return per-community active-channel counts for a given SQL interval. + pub async fn usage_active_channel_counts( + &self, + interval_sql: &'static str, + ) -> Result> { + usage::active_channel_counts(&self.pool, interval_sql).await + } + + /// Return all community id → host mappings. + pub async fn usage_community_hosts(&self) -> Result> { + usage::community_hosts(&self.pool).await + } + /// Begin a database transaction for atomic multi-statement operations. /// /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. @@ -274,6 +476,7 @@ impl Db { SELECT id, host FROM communities WHERE lower(host) = lower($1) + AND archived_at IS NULL "#, ) .bind(normalized_host) @@ -292,6 +495,74 @@ impl Db { .transpose() } + /// Returns whether a community id still exists in the active lifecycle state. + pub async fn is_community_active(&self, community_id: CommunityId) -> Result { + let active = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 AND archived_at IS NULL)", + ) + .bind(community_id.as_uuid()) + .fetch_one(&self.pool) + .await?; + Ok(active) + } + + /// Returns a community by host regardless of lifecycle state. Operator-plane only. + pub async fn lookup_community_by_host_for_management( + &self, + normalized_host: &str, + ) -> Result> { + let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)") + .bind(normalized_host) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(CommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .transpose() + } + + /// Lists communities where `owner_pubkey` currently holds the `owner` role. + /// + /// This is an operator-plane helper, not a tenant-scoped data-plane read: + /// callers must gate it on deployment-level operator auth before exposing it. + pub async fn list_communities_owned_by( + &self, + owner_pubkey: &str, + ) -> Result> { + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let rows = sqlx::query( + r#" + SELECT c.id, c.host, c.created_at, c.archived_at + FROM communities c + JOIN relay_members rm ON rm.community_id = c.id + WHERE rm.pubkey = $1 + AND rm.role = 'owner' + ORDER BY c.created_at ASC, c.host ASC + "#, + ) + .bind(owner_pubkey) + .fetch_all(&self.pool) + .await?; + + rows.into_iter() + .map(|row| { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + let created_at: DateTime = row.try_get("created_at")?; + let archived_at: Option> = row.try_get("archived_at")?; + Ok(OwnedCommunityRecord { + id: CommunityId::from_uuid(id), + host, + created_at, + archived_at, + }) + }) + .collect() + } + /// Returns the normalized host mapped to a community id, if the community /// exists. /// @@ -308,6 +579,7 @@ impl Db { SELECT host FROM communities WHERE id = $1 + AND archived_at IS NULL "#, ) .bind(community_id.as_uuid()) @@ -372,13 +644,13 @@ impl Db { pub async fn ensure_configured_community( &self, normalized_host: &str, - ) -> Result { + ) -> Result { let row = sqlx::query( r#" INSERT INTO communities (host) VALUES ($1) - ON CONFLICT (lower(host)) DO UPDATE SET host = EXCLUDED.host - RETURNING id, host + ON CONFLICT (lower(host)) DO UPDATE SET host = communities.host + RETURNING id, host, (xmax = 0) AS created "#, ) .bind(normalized_host) @@ -387,13 +659,166 @@ impl Db { let id: Uuid = row.try_get("id")?; let host: String = row.try_get("host")?; + let created: bool = row.try_get("created")?; - Ok(CommunityRecord { + Ok(EnsuredCommunityRecord { id: CommunityId::from_uuid(id), host, + created, }) } + /// Atomically creates a community and its initial owner. + /// + /// Holds a per-owner advisory lock while enforcing the ownership limit. + /// Identical create retries return the original record; host collisions and + /// limit failures remain distinguishable to the operator API. + pub async fn create_community_with_owner( + &self, + normalized_host: &str, + owner_pubkey: &str, + ) -> Result { + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let mut tx = self.pool.begin().await?; + + // Serialize on the owner pubkey so concurrent creates to the same + // owner cannot both pass the ownership count check. + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) + .execute(&mut *tx) + .await?; + + let row = sqlx::query( + r#" + INSERT INTO communities (host) + VALUES ($1) + ON CONFLICT (lower(host)) DO NOTHING + RETURNING id, host + "#, + ) + .bind(normalized_host) + .fetch_optional(&mut *tx) + .await?; + + let (id, host) = if let Some(row) = row { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + // Enforce the limit before inserting the new owner row. + let owned_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM relay_members WHERE pubkey = $1 AND role = 'owner'", + ) + .bind(&owner_pubkey) + .fetch_one(&mut *tx) + .await?; + + if owned_count >= relay_members::MAX_COMMUNITIES_PER_OWNER { + tx.rollback().await?; + return Ok(CreateCommunityWithOwnerResult::LimitReached); + } + + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)", + ) + .bind(id) + .bind(&owner_pubkey) + .execute(&mut *tx) + .await?; + (id, host) + } else { + let existing = sqlx::query( + r#" + SELECT c.id, c.host + FROM communities c + JOIN relay_members rm ON rm.community_id = c.id + WHERE lower(c.host) = lower($1) + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + AND c.archived_at IS NULL + "#, + ) + .bind(normalized_host) + .bind(&owner_pubkey) + .fetch_optional(&mut *tx) + .await?; + let Some(existing) = existing else { + tx.rollback().await?; + return Ok(CreateCommunityWithOwnerResult::HostExists); + }; + (existing.try_get("id")?, existing.try_get("host")?) + }; + + tx.commit().await?; + Ok(CreateCommunityWithOwnerResult::Created( + CreatedCommunityRecord { + id: CommunityId::from_uuid(id), + host, + }, + )) + } + + /// Idempotently archives a community when the asserted pubkey is its current owner. + pub async fn archive_community_owned_by( + &self, + normalized_host: &str, + owner_pubkey: &str, + protected_deployment_host: &str, + ) -> Result> { + let row = sqlx::query( + r#"UPDATE communities c + SET archived_at = COALESCE(c.archived_at, now()) + FROM relay_members rm + WHERE lower(c.host) = lower($1) + AND rm.community_id = c.id + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + AND lower(c.host) <> lower($3) + RETURNING c.id, c.host, c.archived_at"#, + ) + .bind(normalized_host) + .bind(owner_pubkey) + .bind(protected_deployment_host) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(ArchivedCommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + archived_at: row.try_get("archived_at")?, + }) + }) + .transpose() + } + + /// Idempotently restores a community when the asserted pubkey is its current owner. + pub async fn unarchive_community_owned_by( + &self, + normalized_host: &str, + owner_pubkey: &str, + ) -> Result> { + let row = sqlx::query( + r#"UPDATE communities c + SET archived_at = NULL + FROM relay_members rm + WHERE lower(c.host) = lower($1) + AND rm.community_id = c.id + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + RETURNING c.id, c.host"#, + ) + .bind(normalized_host) + .bind(owner_pubkey) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(UnarchivedCommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .transpose() + } + /// Returns the community that owns a channel, if the channel exists. /// /// Internal relay producers use this to derive tenant context from the row @@ -609,6 +1034,139 @@ impl Db { event::get_events_by_ids(&self.pool, community_id, ids).await } + /// Exclusively claim the next due event-to-push matcher job. + pub async fn claim_due_push_match( + &self, + lease_until: DateTime, + ) -> Result> { + push::claim_due_match(&self.pool, lease_until).await + } + + /// Load active endpoint-enabled leases eligible for push matching. + pub async fn active_push_match_leases( + &self, + community: CommunityId, + ) -> Result> { + push::active_match_leases(&self.pool, community).await + } + + /// Complete a matcher job if its claim fence is still held. + pub async fn complete_push_match(&self, job: &push::ClaimedMatch) -> Result { + push::complete_match(&self.pool, job).await + } + + /// Release a matcher claim for retry at the supplied time. + pub async fn retry_push_match( + &self, + job: &push::ClaimedMatch, + next: DateTime, + ) -> Result { + push::retry_match(&self.pool, job, next).await + } + + /// Idempotently enqueue a wake for a matched lease and event. + pub async fn enqueue_push_wake( + &self, + community: CommunityId, + author: &[u8], + installation_id: &str, + wake: push::NewWake<'_>, + ) -> Result { + push::enqueue_wake(&self.pool, community, author, installation_id, wake).await + } + + /// Exclusively claim due wake jobs for one community. + pub async fn claim_due_push_wakes( + &self, + community: CommunityId, + limit: i64, + lease_until: DateTime, + ) -> Result> { + push::claim_due_wakes(&self.pool, community, limit, lease_until).await + } + + /// Revalidate a wake's claim, source event, and current lease before send. + pub async fn revalidate_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + ) -> Result { + push::revalidate_wake_for_send(&self.pool, community, id, claim_id).await + } + + /// Mark a fenced wake claim delivered. + pub async fn complete_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + ) -> Result { + push::complete_wake(&self.pool, community, id, claim_id).await + } + + /// Release a fenced wake claim for retry at the supplied time. + pub async fn retry_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + push::retry_wake(&self.pool, community, id, claim_id, next).await + } + + /// Mark a fenced wake claim terminally failed. + pub async fn fail_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + ) -> Result { + push::fail_wake(&self.pool, community, id, claim_id).await + } + + /// Disable an endpoint only if the specified lease generation is current. + pub async fn disable_push_endpoint( + &self, + community: CommunityId, + author: &[u8], + installation_id: &str, + generation: i64, + ) -> Result { + push::disable_endpoint_generation( + &self.pool, + community, + author, + installation_id, + generation, + ) + .await + } + + /// Atomically persist a validated kind:30350 event and its effective lease. + #[allow(clippy::too_many_arguments)] + pub async fn accept_push_lease_event( + &self, + community: CommunityId, + event: &nostr::Event, + installation_id: &str, + version: push::LeaseVersion<'_>, + active: Option>, + max_active_leases: i64, + ) -> Result { + push::accept_lease_event( + &self.pool, + community, + event, + installation_id, + version, + active, + max_active_leases, + ) + .await + } + /// Atomically insert an event AND its thread metadata in a single transaction. pub async fn insert_event_with_thread_metadata( &self, @@ -1016,7 +1574,11 @@ impl Db { } /// Ensure a user record exists (upsert). - pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result<()> { + /// + /// Returns `true` if a new row was inserted (first time), `false` if it + /// already existed. Callers use the `true` return to increment + /// `buzz_users_created_total`. + pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result { user::ensure_user(&self.pool, community_id, pubkey).await } @@ -2165,6 +2727,25 @@ impl Db { relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await } + /// Atomically transfers ownership of `community` to `new_owner_pubkey`, + /// demoting the previous owner(s) to `member`. Verifies + /// `expected_owner_pubkey` matches the current owner inside the same + /// transaction to prevent stale-owner races. + pub async fn transfer_ownership( + &self, + community: CommunityId, + new_owner_pubkey: &str, + expected_owner_pubkey: &str, + ) -> Result { + relay_members::transfer_ownership( + &self.pool, + community, + new_owner_pubkey, + expected_owner_pubkey, + ) + .await + } + /// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`. /// /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows @@ -2173,6 +2754,168 @@ impl Db { relay_members::backfill_from_allowlist(&self.pool, community).await } + /// Sidecar an accepted product-feedback event, idempotent by event id. + pub async fn insert_product_feedback( + &self, + community: CommunityId, + feedback: product_feedback::NewProductFeedback<'_>, + ) -> Result { + product_feedback::insert(&self.pool, community, feedback).await + } + + /// List product feedback across the deployment, newest first. + pub async fn list_product_feedback( + &self, + limit: i64, + ) -> Result> { + product_feedback::list(&self.pool, limit).await + } + + /// Insert a tenant-scoped NIP-56 report row, idempotent by report event id. + pub async fn insert_moderation_report( + &self, + community: CommunityId, + report: moderation::NewReport<'_>, + ) -> Result { + moderation::insert_report(&self.pool, community, report).await + } + + /// List moderation reports for a community, newest first. + pub async fn list_moderation_reports( + &self, + community: CommunityId, + status: Option<&str>, + limit: i64, + ) -> Result> { + moderation::list_reports(&self.pool, community, status, limit).await + } + + /// Fetch one moderation report by row id. + pub async fn get_moderation_report( + &self, + community: CommunityId, + report_id: Uuid, + ) -> Result> { + moderation::get_report(&self.pool, community, report_id).await + } + + /// Fetch one moderation report by signed NIP-56 report event id. + pub async fn get_moderation_report_by_event( + &self, + community: CommunityId, + report_event_id: &[u8], + ) -> Result> { + moderation::get_report_by_event(&self.pool, community, report_event_id).await + } + + /// Resolve, dismiss, or escalate an open moderation report. + pub async fn resolve_moderation_report( + &self, + community: CommunityId, + report_id: Uuid, + status: &str, + resolved_by: &[u8], + action_id: Option, + ) -> Result { + moderation::resolve_report( + &self.pool, + community, + report_id, + status, + resolved_by, + action_id, + ) + .await + } + + /// Upsert a community ban for a member pubkey. + pub async fn ban_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + reason: Option<&str>, + expires_at: Option>, + ) -> Result<()> { + moderation::ban_member(&self.pool, community, pubkey, actor, reason, expires_at).await + } + + /// Lift a community ban for a member pubkey. + pub async fn unban_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + ) -> Result { + moderation::unban_member(&self.pool, community, pubkey, actor).await + } + + /// Upsert a community timeout/write-block for a member pubkey. + pub async fn timeout_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + muted_until: DateTime, + reason: Option<&str>, + ) -> Result<()> { + moderation::timeout_member(&self.pool, community, pubkey, actor, muted_until, reason).await + } + + /// Clear a community timeout/write-block for a member pubkey. + pub async fn untimeout_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + ) -> Result { + moderation::untimeout_member(&self.pool, community, pubkey, actor).await + } + + /// Fetch the active ban/timeout restriction state for enforcement hot paths. + pub async fn moderation_restriction_state( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result { + moderation::restriction_state(&self.pool, community, pubkey).await + } + + /// Fetch the full ban/timeout row for a member pubkey. + pub async fn get_community_ban( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result> { + moderation::get_ban(&self.pool, community, pubkey).await + } + + /// List currently restricted members in a community. + pub async fn list_community_restrictions( + &self, + community: CommunityId, + ) -> Result> { + moderation::list_restricted(&self.pool, community).await + } + + /// Insert a moderation audit action row. + pub async fn insert_moderation_action( + &self, + community: CommunityId, + action: moderation::NewAction<'_>, + ) -> Result { + moderation::insert_action(&self.pool, community, action).await + } + + /// List moderation audit action rows, newest first. + pub async fn list_moderation_actions( + &self, + community: CommunityId, + limit: i64, + ) -> Result> { + moderation::list_actions(&self.pool, community, limit).await + } + /// Return the current owner of git repo name `repo_id` in `community`, or /// `None` if unreserved. See [`git_repo::repo_name_owner`]. pub async fn repo_name_owner( @@ -2298,31 +3041,13 @@ impl Db { let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) .ok_or(DbError::InvalidTimestamp(created_at_secs))?; - // Stable advisory-lock key: hash (kind, pubkey, channel_id) to i64. - // Uses FNV-1a for determinism — Rust's DefaultHasher is NOT stable across processes. - // Collisions cause extra serialization, not incorrect behavior. - let lock_key = { - let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis - for b in community_id.as_uuid().as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in kind_i32.to_le_bytes() { - h ^= b as u64; - h = h.wrapping_mul(0x100000001b3); // FNV prime - } - for b in pubkey_bytes.as_slice() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - if let Some(ch) = channel_id { - for b in ch.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - } - h as i64 - }; + // Collisions only cause extra serialization; they cannot change behavior. + let lock_key = event_replacement_lock_key( + community_id, + kind_i32, + pubkey_bytes.as_slice(), + channel_id.as_ref().map(|id| id.as_bytes().as_slice()), + ); let mut tx = self.pool.begin().await?; @@ -2430,12 +3155,144 @@ impl Db { )) } + /// Atomically publish a NIP-43 membership snapshot under a single + /// transaction-scoped advisory lock. + /// + /// This method acquires the per-community snapshot lock, reads the + /// current membership, builds the event, and replaces the prior snapshot + /// — all inside one transaction on one database connection. This + /// prevents the stale-snapshot race where a concurrent publication reads + /// older state and overwrites a newer snapshot by arrival order. + /// + pub async fn publish_nip43_membership_locked( + &self, + community_id: CommunityId, + relay_keypair: &nostr::Keys, + ) -> Result<(StoredEvent, bool, usize)> { + use nostr::{EventBuilder, Kind, Tag}; + + let kind_i32 = buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32; + let pubkey_bytes = relay_keypair.public_key().to_bytes(); + + let lock_key = + event_replacement_lock_key(community_id, kind_i32, pubkey_bytes.as_slice(), None); + + let mut tx = self.pool.begin().await?; + + // Acquire the per-community snapshot lock BEFORE reading members. + // This serializes the entire read-build-write cycle: a concurrent + // publication will block here until our transaction commits, then + // read the updated membership state. + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + + // Read current members inside the locked transaction. + let rows = sqlx::query( + "SELECT pubkey, role FROM relay_members \ + WHERE community_id = $1 ORDER BY created_at ASC", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut *tx) + .await?; + + let member_count = rows.len(); + + // Build the NIP-43 event from the locked member rows. + let mut tags: Vec = Vec::with_capacity(member_count + 1); + // NIP-70 protected-event marker. + tags.push(Tag::parse(["-"]).map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to build '-' tag: {e}")) + })?); + for row in &rows { + let pubkey: String = row.try_get("pubkey")?; + let role: String = row.try_get("role")?; + tags.push(Tag::parse(["member", &pubkey, &role]).map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to build member tag: {e}")) + })?); + } + + let event = EventBuilder::new(Kind::Custom(kind_i32 as u16), "") + .tags(tags) + .sign_with_keys(relay_keypair) + .map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to sign kind:13534: {e}")) + })?; + + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + let sig_bytes = event.sig.serialize(); + let tags_json = serde_json::to_value(&event.tags)?; + let received_at = chrono::Utc::now(); + let d_tag = crate::event::extract_d_tag(&event); + + // Soft-delete prior snapshots — unconditional, the relay is authoritative. + sqlx::query( + "UPDATE events SET deleted_at = NOW() \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NULL \ + AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(pubkey_bytes.as_slice()) + .execute(&mut *tx) + .await?; + + let insert_result = sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(pubkey_bytes.as_slice()) + .bind(created_at) + .bind(kind_i32) + .bind(&tags_json) + .bind(&event.content) + .bind(sig_bytes.as_slice()) + .bind(received_at) + .bind::>(None) + .bind(d_tag.as_deref()) + .execute(&mut *tx) + .await?; + + let was_inserted = insert_result.rows_affected() > 0; + if !was_inserted { + tx.rollback().await?; + return Ok(( + StoredEvent::with_received_at(event, received_at, None, false), + false, + member_count, + )); + } + + tx.commit().await?; + + if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + + Ok(( + StoredEvent::with_received_at(event, received_at, None, true), + true, + member_count, + )) + } + /// Atomically replace a NIP-33 parameterized replaceable event (kind 30000–39999). /// /// Keeps only the event with the highest `created_at` per `(kind, pubkey, d_tag)`. /// Same-second ties are broken by lowest event `id` (deterministic ordering). - /// The entire check → soft-delete → insert runs in a single transaction with - /// an advisory lock to prevent concurrent-insert races. + /// The entire check → retire old payload → insert runs in a single transaction + /// with an advisory lock to prevent concurrent-insert races. NIP-RS read-state + /// coordinates hard-delete the superseded payload and preserve a compact + /// ordering watermark. Buzz mesh status coordinates also hard-delete their + /// superseded heartbeat payload because only the live head has product + /// value; other NIP-33 kinds retain soft-deleted history. /// /// **Channel policy:** NIP-33 replacement keys on `(kind, pubkey, d_tag)` globally — /// `channel_id` is NOT part of the replacement key. This matches the Nostr spec: @@ -2460,28 +3317,12 @@ impl Db { let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) .ok_or(DbError::InvalidTimestamp(created_at_secs))?; - // Stable advisory-lock key: FNV-1a over (kind, pubkey, d_tag). - // Same algorithm as replace_addressable_event — deterministic across processes. - let lock_key = { - let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis - for b in community_id.as_uuid().as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in kind_i32.to_le_bytes() { - h ^= b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in pubkey_bytes.as_slice() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in d_tag.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - h as i64 - }; + let lock_key = event_replacement_lock_key( + community_id, + kind_i32, + pubkey_bytes.as_slice(), + Some(d_tag.as_bytes()), + ); let mut tx = self.pool.begin().await?; @@ -2490,7 +3331,44 @@ impl Db { .execute(&mut *tx) .await?; - // Check for existing event with same (kind, pubkey, d_tag). + let d_tag_count = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().is_some_and(|part| part == "d")) + .count(); + let has_exact_d_tag = event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.len() >= 2 && parts[0] == "d" && parts[1] == d_tag + }); + let read_state_t_tag_count = event + .tags + .iter() + .filter(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == "t" && parts[1] == "read-state" + }) + .count(); + let is_nip_rs = kind_i32 == buzz_core::kind::KIND_READ_STATE as i32 + && d_tag_count == 1 + && has_exact_d_tag + && d_tag.strip_prefix("read-state:").is_some_and(|slot| { + slot.len() == 32 + && slot + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + }) + && read_state_t_tag_count == 1; + let is_buzz_mesh_status = kind_i32 == buzz_core::kind::KIND_BOOKMARK_SET as i32 + && d_tag.starts_with("buzz-mesh-member-status:") + && event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == "k" && parts[1] == "buzz-mesh-status" + }); + let hard_delete_superseded = is_nip_rs || is_buzz_mesh_status; + + // Check the live head and, for NIP-RS, the compact historical ordering + // watermark. The watermark remains after a NIP-09 coordinate deletion, + // preventing a previously accepted signed blob from being resurrected. let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( "SELECT created_at, id FROM events \ WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ @@ -2502,32 +3380,78 @@ impl Db { .bind(d_tag) .fetch_optional(&mut *tx) .await?; - - // Stale-write protection: reject if incoming is not newer. - let incoming_id = event.id.as_bytes().as_slice(); - if let Some((existing_ts, existing_id)) = existing { - let dominated = created_at < existing_ts - || (created_at == existing_ts && incoming_id >= existing_id.as_slice()); - if dominated { - tx.rollback().await?; - let received_at = chrono::Utc::now(); - return Ok(( - StoredEvent::with_received_at(event.clone(), received_at, channel_id, false), - false, - )); - } - - // Soft-delete the older event(s). - sqlx::query( - "UPDATE events SET deleted_at = NOW() \ - WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL", + let watermark: Option<(chrono::DateTime, Vec)> = if is_nip_rs { + sqlx::query_as( + "SELECT created_at, event_id FROM parameterized_event_watermarks \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4", ) .bind(community_id.as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(d_tag) - .execute(&mut *tx) - .await?; + .fetch_optional(&mut *tx) + .await? + } else { + None + }; + + // Stale-write protection: reject if either durable ordering source + // dominates the incoming tuple. Equal timestamps use lowest event id. + let incoming_id = event.id.as_bytes().as_slice(); + let dominated = + existing + .iter() + .chain(watermark.iter()) + .any(|(accepted_ts, accepted_id)| { + created_at < *accepted_ts + || (created_at == *accepted_ts && incoming_id >= accepted_id.as_slice()) + }); + if dominated { + tx.rollback().await?; + let received_at = chrono::Utc::now(); + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, channel_id, false), + false, + )); + } + + if existing.is_some() { + if is_nip_rs { + // Migration 0011 rejects regex-coordinate hard deletes from + // pre-fix writers. Authorize only this corrected NIP-RS delete, + // transaction-locally so pooled connections cannot leak it. + sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)") + .execute(&mut *tx) + .await?; + } + let statement = if hard_delete_superseded { + "DELETE FROM events \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL" + } else { + "UPDATE events SET deleted_at = NOW() \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL" + }; + sqlx::query(statement) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(pubkey_bytes.as_slice()) + .bind(d_tag) + .execute(&mut *tx) + .await?; + + if hard_delete_superseded { + if let Some((_, existing_id)) = &existing { + // Event first, mentions second: migration 0009's live-event + // fence uses this global lock order to avoid deadlocks. + sqlx::query( + "DELETE FROM event_mentions WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id.as_uuid()) + .bind(existing_id) + .execute(&mut *tx) + .await?; + } + } } // Insert the new event inside the transaction. @@ -2564,6 +3488,24 @@ impl Db { )); } + if is_nip_rs { + sqlx::query( + "INSERT INTO parameterized_event_watermarks \ + (community_id, kind, pubkey, d_tag, created_at, event_id) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (community_id, kind, pubkey, d_tag) DO UPDATE SET \ + created_at = EXCLUDED.created_at, event_id = EXCLUDED.event_id", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(pubkey_bytes.as_slice()) + .bind(d_tag) + .bind(created_at) + .bind(incoming_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; // Mentions are a denormalized index — safe outside the transaction. @@ -2662,13 +3604,16 @@ mod tests { //! channels, that fail-closed chain would go blind. use super::*; use buzz_core::CommunityId; - use sqlx::PgPool; + use sqlx::postgres::PgPoolOptions; + use sqlx::{Acquire, PgPool}; use uuid::Uuid; const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; async fn setup_db() -> Db { - let pool = PgPool::connect(TEST_DB_URL) + let database_url = + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); Db::from_pool(pool) @@ -2686,6 +3631,740 @@ mod tests { id } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_rs_replacement_hard_deletes_payload_and_watermark_rejects_replay() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let d_tag = format!("read-state:{}", "a".repeat(32)); + let tags = vec![ + Tag::parse(["d", d_tag.as_str()]).expect("d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ]; + let base = Timestamp::now().as_secs(); + let old = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "old") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign old"); + let new = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "new") + .tags(tags) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign new"); + + assert!( + db.replace_parameterized_event(community, &old, &d_tag, None) + .await + .expect("insert old") + .1 + ); + assert!( + db.replace_parameterized_event(community, &new, &d_tag, None) + .await + .expect("replace with new") + .1 + ); + + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count NIP-RS rows"); + assert_eq!(rows, 1, "superseded payload must be physically deleted"); + + sqlx::query( + "UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .execute(&db.pool) + .await + .expect("simulate NIP-09 coordinate deletion"); + + assert!( + !db.replace_parameterized_event(community, &old, &d_tag, None) + .await + .expect("replay old") + .1 + ); + let live: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count live NIP-RS rows"); + assert_eq!(live, 0, "watermark must block stale resurrection"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn mesh_status_replacement_keeps_one_physical_row() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let d_tag = "buzz-mesh-member-status:owner-test"; + let tags = vec![ + Tag::parse(["d", d_tag]).expect("d tag"), + Tag::parse(["k", "buzz-mesh-status"]).expect("k tag"), + ]; + let base = Timestamp::now().as_secs(); + for (offset, content) in [(0, "running"), (1, "running-again"), (2, "stopped")] { + let event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_BOOKMARK_SET as u16), + content, + ) + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base + offset)) + .sign_with_keys(&keys) + .expect("sign mesh status"); + assert!( + db.replace_parameterized_event(community, &event, d_tag, None) + .await + .expect("replace mesh status") + .1 + ); + } + + let (rows, live): (i64, i64) = sqlx::query_as( + "SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \ + WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(d_tag) + .fetch_one(&db.pool) + .await + .expect("count mesh status rows"); + assert_eq!((rows, live), (1, 1)); + + sqlx::query( + "UPDATE events SET deleted_at=NOW() \ + WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(d_tag) + .execute(&db.pool) + .await + .expect("simulate old relay soft delete"); + let rows_after_legacy_delete: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(d_tag) + .fetch_one(&db.pool) + .await + .expect("count rows after old relay soft delete"); + assert_eq!( + rows_after_legacy_delete, 0, + "migration trigger must purge soft-deleted mesh status" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let base = Timestamp::now().as_secs(); + + for (case, tags) in [ + ( + "duplicate-d", + vec![ + Tag::parse(["d", &format!("read-state:{}", "c".repeat(32))]) + .expect("first d tag"), + Tag::parse(["d", &format!("read-state:{}", "d".repeat(32))]) + .expect("second d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ], + ), + ( + "duplicate-t", + vec![ + Tag::parse(["d", &format!("read-state:{}", "e".repeat(32))]).expect("d tag"), + Tag::parse(["t", "read-state"]).expect("first t tag"), + Tag::parse(["t", "read-state"]).expect("second t tag"), + ], + ), + ] { + let d_tag = tags + .iter() + .find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().is_some_and(|part| part == "d") && parts.len() >= 2) + .then(|| parts[1].clone()) + }) + .expect("first d-tag value"); + let old = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + format!("{case}-old"), + ) + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign old event"); + let new = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + format!("{case}-new"), + ) + .tags(tags) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign new event"); + + assert!( + db.replace_parameterized_event(community, &old, &d_tag, None) + .await + .expect("insert old event") + .1 + ); + assert!( + db.replace_parameterized_event(community, &new, &d_tag, None) + .await + .expect("replace with new event") + .1 + ); + + let (rows, live): (i64, i64) = sqlx::query_as( + "SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count retained rows"); + assert_eq!((rows, live), (2, 1), "{case} must retain legacy history"); + + let watermarks: i64 = sqlx::query_scalar( + "SELECT count(*) FROM parameterized_event_watermarks \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count watermarks"); + assert_eq!(watermarks, 0, "{case} must not create a watermark"); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let base = Timestamp::now().as_secs(); + let conforming_d = format!("read-state:{}", "6".repeat(32)); + let conforming = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + "fenced-conforming", + ) + .tags(vec![ + Tag::parse(["d", conforming_d.as_str()]).expect("d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ]) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign conforming event"); + assert!( + db.replace_parameterized_event(community, &conforming, &conforming_d, None) + .await + .expect("insert conforming event") + .1 + ); + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("6".repeat(64)) + .bind(conforming.id.as_bytes().as_slice()) + .bind(conforming.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert mention"); + + // Model ce10's first destructive statement. RAISE aborts the transaction, + // so its later mention delete and incoming insert can never commit. + let mut old_writer = db.pool.begin().await.expect("begin old-writer tx"); + let rejected = sqlx::query( + "DELETE FROM events WHERE community_id=$1 AND kind=30078 \ + AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&conforming_d) + .execute(&mut *old_writer) + .await; + assert!(rejected.is_err(), "old-writer hard delete must be rejected"); + old_writer.rollback().await.expect("rollback rejected tx"); + let preserved: (i64, i64) = sqlx::query_as( + "SELECT (SELECT count(*) FROM events WHERE community_id=$1 AND id=$2), \ + (SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2)", + ) + .bind(community.as_uuid()) + .bind(conforming.id.as_bytes().as_slice()) + .fetch_one(&db.pool) + .await + .expect("count preserved payload and mention"); + assert_eq!(preserved, (1, 1)); + + let nonconforming_d = format!("read-state:{}", "7".repeat(32)); + let nonconforming = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), + "fenced-nonconforming", + ) + .tags(vec![ + Tag::parse(["d", nonconforming_d.as_str()]).expect("first d tag"), + Tag::parse(["d", "other"]).expect("second d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ]) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign nonconforming event"); + assert!( + db.replace_parameterized_event(community, &nonconforming, &nonconforming_d, None,) + .await + .expect("insert nonconforming event") + .1 + ); + let rejected_nonconforming = sqlx::query( + "DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)", + ) + .bind(community.as_uuid()) + .bind(nonconforming.id.as_bytes().as_slice()) + .bind(nonconforming.created_at.as_secs() as f64) + .execute(&db.pool) + .await; + assert!( + rejected_nonconforming.is_err(), + "fence must cover a nonconforming OLD row at a regex coordinate" + ); + + let unrelated_d = format!("read-state:{}", "8".repeat(32)); + let unrelated = EventBuilder::new(Kind::Custom(30023), "unrelated") + .tags(vec![Tag::parse(["d", unrelated_d.as_str()]).expect("d tag")]) + .custom_created_at(Timestamp::from(base + 2)) + .sign_with_keys(&keys) + .expect("sign unrelated event"); + assert!( + db.replace_parameterized_event(community, &unrelated, &unrelated_d, None) + .await + .expect("insert unrelated event") + .1 + ); + let unrelated_delete = sqlx::query( + "DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)", + ) + .bind(community.as_uuid()) + .bind(unrelated.id.as_bytes().as_slice()) + .bind(unrelated.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("delete unrelated event"); + assert_eq!(unrelated_delete.rows_affected(), 1); + + // Check both transaction exits on one physical session; pool selection + // cannot accidentally hide a leaked session-local authorization value. + let mut conn = db.pool.acquire().await.expect("acquire dedicated session"); + for commit in [true, false] { + let mut tx = conn.begin().await.expect("begin GUC transaction"); + let value: String = + sqlx::query_scalar("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)") + .fetch_one(&mut *tx) + .await + .expect("set transaction-local GUC"); + assert_eq!(value, "on"); + if commit { + tx.commit().await.expect("commit GUC transaction"); + } else { + tx.rollback().await.expect("rollback GUC transaction"); + } + let leaked: Option = sqlx::query_scalar( + "SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')", + ) + .fetch_one(&mut *conn) + .await + .expect("read GUC after transaction"); + assert_ne!(leaked.as_deref(), Some("on")); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn database_guard_covers_legacy_writer_and_nip09_deletion() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let d_tag = format!("read-state:{}", "b".repeat(32)); + let tags = vec![ + Tag::parse(["d", d_tag.as_str()]).expect("d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ]; + let base = Timestamp::now().as_secs(); + let a = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "A") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign A"); + let x = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "X") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign X"); + let b = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "B") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base + 2)) + .sign_with_keys(&keys) + .expect("sign B"); + let c = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "C") + .tags(tags) + .custom_created_at(Timestamp::from(base + 3)) + .sign_with_keys(&keys) + .expect("sign C"); + + async fn legacy_insert( + pool: &PgPool, + community: CommunityId, + event: &nostr::Event, + d_tag: &str, + ) -> std::result::Result { + sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, d_tag) \ + VALUES ($1, $2, $3, to_timestamp($4), $5, $6, $7, $8, NOW(), $9) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(event.pubkey.to_bytes()) + .bind(event.created_at.as_secs() as f64) + .bind(buzz_core::kind::KIND_READ_STATE as i32) + .bind(serde_json::to_value(&event.tags).expect("serialize tags")) + .bind(&event.content) + .bind(event.sig.serialize().as_slice()) + .bind(d_tag) + .execute(pool) + .await + } + + legacy_insert(&db.pool, community, &a, &d_tag) + .await + .expect("legacy insert A"); + let duplicate = legacy_insert(&db.pool, community, &a, &d_tag) + .await + .expect("legacy duplicate A remains idempotent"); + assert_eq!(duplicate.rows_affected(), 0); + + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("c".repeat(64)) + .bind(a.id.as_bytes().as_slice()) + .bind(a.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert live mention"); + + // Emulate the pre-PR replacement path after migration 0007: soft-delete + // the live row, then insert B without any application watermark write. + sqlx::query( + "UPDATE events SET deleted_at=NOW() \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .execute(&db.pool) + .await + .expect("legacy soft-delete A"); + let mentions_after_delete: i64 = sqlx::query_scalar( + "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", + ) + .bind(community.as_uuid()) + .bind(a.id.as_bytes().as_slice()) + .fetch_one(&db.pool) + .await + .expect("count mentions after delete"); + assert_eq!(mentions_after_delete, 0); + + let stale_mention = sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("d".repeat(64)) + .bind(a.id.as_bytes().as_slice()) + .bind(a.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("stale post-commit mention is skipped"); + assert_eq!(stale_mention.rows_affected(), 0); + + legacy_insert(&db.pool, community, &b, &d_tag) + .await + .expect("legacy insert B"); + let duplicate_b = legacy_insert(&db.pool, community, &b, &d_tag) + .await + .expect("live duplicate B is skipped"); + assert_eq!(duplicate_b.rows_affected(), 0); + + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("e".repeat(64)) + .bind(b.id.as_bytes().as_slice()) + .bind(b.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert B mention"); + + // Exercise the new Rust hard-delete path independently. An in-flight + // mention holds KEY SHARE on B, so replacement by C must block, then + // complete after the mention commits and remove both B and its mention. + let mut rust_mention_tx = db + .pool + .begin() + .await + .expect("begin Rust mention transaction"); + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind("e".repeat(64)) + .bind(b.id.as_bytes().as_slice()) + .bind(b.created_at.as_secs() as f64) + .execute(&mut *rust_mention_tx) + .await + .expect("hold B live-event key-share lock"); + + let replace_db = db.clone(); + let replace_d_tag = d_tag.clone(); + let replace_c = c.clone(); + let replace_task = tokio::spawn(async move { + replace_db + .replace_parameterized_event(community, &replace_c, &replace_d_tag, None) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + !replace_task.is_finished(), + "Rust hard delete should wait for mention lock" + ); + rust_mention_tx + .commit() + .await + .expect("release Rust mention lock"); + let replaced = tokio::time::timeout(std::time::Duration::from_secs(2), replace_task) + .await + .expect("Rust hard delete deadlocked with mention insert") + .expect("replacement task panicked") + .expect("replace B with C"); + assert!(replaced.1, "C must replace B"); + let b_mentions: i64 = sqlx::query_scalar( + "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", + ) + .bind(community.as_uuid()) + .bind(b.id.as_bytes().as_slice()) + .fetch_one(&db.pool) + .await + .expect("count B mentions after Rust replacement"); + assert_eq!(b_mentions, 0); + + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("f".repeat(64)) + .bind(c.id.as_bytes().as_slice()) + .bind(c.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert C mention"); + + // Exercise legacy UPDATE-trigger deletion with the same barrier. While + // deletion waits on C's KEY SHARE lock, an exact replay must already be + // a zero-row trigger no-op; it must not wait for deletion or resurrect C. + let mut legacy_mention_tx = db + .pool + .begin() + .await + .expect("begin legacy mention transaction"); + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind("f".repeat(64)) + .bind(c.id.as_bytes().as_slice()) + .bind(c.created_at.as_secs() as f64) + .execute(&mut *legacy_mention_tx) + .await + .expect("hold C live-event key-share lock"); + + let delete_pool = db.pool.clone(); + let delete_pubkey = keys.public_key().to_bytes(); + let delete_d_tag = d_tag.clone(); + let delete_task = tokio::spawn(async move { + sqlx::query( + "UPDATE events SET deleted_at=NOW() \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(delete_pubkey) + .bind(delete_d_tag) + .execute(&delete_pool) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + !delete_task.is_finished(), + "legacy delete should wait for mention lock" + ); + + let replay_while_delete_waits = legacy_insert(&db.pool, community, &c, &d_tag) + .await + .expect("concurrent exact C replay is skipped"); + assert_eq!(replay_while_delete_waits.rows_affected(), 0); + + legacy_mention_tx + .commit() + .await + .expect("release legacy mention lock"); + tokio::time::timeout(std::time::Duration::from_secs(2), delete_task) + .await + .expect("legacy delete deadlocked with mention insert") + .expect("delete task panicked") + .expect("legacy NIP-09 delete C"); + + let payloads: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count retained payloads"); + assert_eq!( + payloads, 0, + "legacy soft deletes must not retain NIP-RS payloads" + ); + + // Opposite commit order: deletion has committed before exact replay. + // Equality remains an observable zero-row no-op, never a resurrection. + let replay_c = legacy_insert(&db.pool, community, &c, &d_tag) + .await + .expect("post-delete exact C replay is skipped"); + assert_eq!(replay_c.rows_affected(), 0); + let payloads_after_exact_replay: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count payloads after exact replay"); + assert_eq!(payloads_after_exact_replay, 0); + + let replay = legacy_insert(&db.pool, community, &x, &d_tag).await; + assert!( + replay.is_err(), + "database guard must reject A < X < C replay" + ); + + let watermark: (chrono::DateTime, Vec) = sqlx::query_as( + "SELECT created_at, event_id FROM parameterized_event_watermarks \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("read C watermark"); + assert_eq!(watermark.0.timestamp(), base as i64 + 3); + assert_eq!(watermark.1, c.id.as_bytes().as_slice()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_usage_metrics_lock_has_single_owner_and_releases_on_drop() { + let pool = PgPoolOptions::new() + .max_connections(2) + .connect(TEST_DB_URL) + .await + .expect("connect to test DB"); + let first = Db::from_pool(pool.clone()); + let second = Db::from_pool(pool); + let key = 0x4255_5A5A_4D45_5452; + + let mut leader = first + .try_lock_usage_metrics(key) + .await + .expect("first lock attempt") + .expect("first database handle becomes leader"); + assert!(leader.is_live().await, "lock owner remains reachable"); + assert!( + second + .try_lock_usage_metrics(key) + .await + .expect("second lock attempt") + .is_none(), + "another session cannot become leader while the guard exists" + ); + + drop(leader); + assert!( + second + .try_lock_usage_metrics(key) + .await + .expect("lock attempt after leader drop") + .is_some(), + "dropping the detached session releases its advisory lock" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn lookup_community_by_host_matches_case_insensitive_host_index() { @@ -2717,6 +4396,247 @@ mod tests { assert_eq!(found.id, CommunityId::from_uuid(id)); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_community_with_owner_is_atomic_and_create_only() { + let db = setup_db().await; + let host = format!("create-only-{}.example", Uuid::new_v4().simple()); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + let created = db + .create_community_with_owner(&host, owner) + .await + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + assert_eq!(created.host, host); + let owner_role: Option = sqlx::query_scalar( + "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", + ) + .bind(created.id.as_uuid()) + .bind(owner) + .fetch_optional(&db.pool) + .await + .expect("owner role"); + assert_eq!(owner_role.as_deref(), Some("owner")); + + let retry = db + .create_community_with_owner(&host.to_ascii_uppercase(), owner) + .await + .expect("same-owner retry"); + assert_eq!( + retry, + CreateCommunityWithOwnerResult::Created(created.clone()), + "retry returns the original row" + ); + + let collision = db + .create_community_with_owner(&host, other) + .await + .expect("collision result"); + assert_eq!(collision, CreateCommunityWithOwnerResult::HostExists); + let roles: Vec<(String, String)> = sqlx::query_as( + "SELECT pubkey, role FROM relay_members WHERE community_id = $1 ORDER BY pubkey", + ) + .bind(created.id.as_uuid()) + .fetch_all(&db.pool) + .await + .expect("community roles"); + assert_eq!(roles, vec![(owner.to_string(), "owner".to_string())]); + + db.bootstrap_owner(created.id, other) + .await + .expect("rotate owner"); + let post_rotation_retry = db + .create_community_with_owner(&host, owner) + .await + .expect("post-rotation retry"); + assert_eq!( + post_rotation_retry, + CreateCommunityWithOwnerResult::HostExists + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unarchive_community_owned_by_restores_admission_idempotently() { + let db = setup_db().await; + let host = format!("unarchive-{}.example", Uuid::new_v4().simple()); + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let outsider = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let created = db + .create_community_with_owner(&host, &owner) + .await + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + + let archived = db + .archive_community_owned_by(&host, &owner, "protected.example") + .await + .expect("archive community") + .expect("owned community"); + assert_eq!(archived.id, created.id); + assert!( + db.lookup_community_by_host(&host) + .await + .expect("active lookup") + .is_none(), + "archived communities must fail admission" + ); + assert!(db + .unarchive_community_owned_by(&host, &outsider) + .await + .expect("wrong-owner unarchive") + .is_none()); + assert!(db + .unarchive_community_owned_by("missing.example", &owner) + .await + .expect("unknown-host unarchive") + .is_none()); + + let restored = db + .unarchive_community_owned_by(&host.to_ascii_uppercase(), &owner) + .await + .expect("unarchive community") + .expect("owned community"); + assert_eq!(restored.id, created.id); + assert_eq!(restored.host, host); + assert_eq!( + db.lookup_community_by_host(&host) + .await + .expect("restored lookup") + .expect("active community") + .id, + created.id + ); + assert_eq!( + db.get_relay_member(created.id, &owner) + .await + .expect("owner lookup") + .expect("owner remains") + .role, + "owner" + ); + + let retry = db + .unarchive_community_owned_by(&host, &owner) + .await + .expect("idempotent retry") + .expect("owned community"); + assert_eq!(retry, restored); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_community_with_owner_enforces_per_owner_limit() { + let db = setup_db().await; + let owner = format!("{:064x}", Uuid::new_v4().as_u128()); + + // Create 3 communities for this owner (the max). + for i in 0..3 { + let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); + assert!(matches!( + db.create_community_with_owner(&host, &owner) + .await + .expect("create community"), + CreateCommunityWithOwnerResult::Created(_) + )); + } + + let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple()); + assert_eq!( + db.create_community_with_owner(&host, &owner) + .await + .expect("create community call"), + CreateCommunityWithOwnerResult::LimitReached + ); + assert!( + db.lookup_community_by_host(&host) + .await + .expect("look up rolled-back fresh host") + .is_none(), + "limit rejection must roll back the fresh community row" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_same_owner_create_returns_the_winning_row_to_both_callers() { + let db = setup_db().await; + let host = format!("concurrent-create-{}.example", Uuid::new_v4().simple()); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + let (first, second) = tokio::join!( + db.create_community_with_owner(&host, owner), + db.create_community_with_owner(&host, owner), + ); + let first = first.expect("first concurrent create"); + let second = second.expect("second concurrent create"); + + assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_))); + assert_eq!(first, second, "conflict loser re-reads the winning row"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ensure_configured_community_reports_insert_winner() { + let db = setup_db().await; + let host = format!("ensure-community-{}.example", Uuid::new_v4().simple()); + + let first = db + .ensure_configured_community(&host) + .await + .expect("first ensure"); + assert!(first.created, "first ensure should report created"); + assert_eq!(first.host, host); + + let second = db + .ensure_configured_community(&host) + .await + .expect("second ensure"); + assert!(!second.created, "second ensure should report existed"); + assert_eq!(second.id, first.id); + assert_eq!(second.host, host); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_communities_owned_by_returns_only_owner_rows() { + let db = setup_db().await; + let community_a = CommunityId::from_uuid(make_community(&db.pool).await); + let community_b = CommunityId::from_uuid(make_community(&db.pool).await); + let community_c = CommunityId::from_uuid(make_community(&db.pool).await); + // Unique per run: `list_communities_owned_by` is keyed only by pubkey, + // so a shared fixed pubkey picks up communities leaked by sibling + // ignored tests running against the same database. + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let owner = owner.as_str(); + let other = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let other = other.as_str(); + + db.bootstrap_owner(community_a, owner) + .await + .expect("owner A"); + db.bootstrap_owner(community_b, other) + .await + .expect("other owner B"); + db.add_relay_member(community_c, owner, "admin", None) + .await + .expect("admin C"); + + let owned = db + .list_communities_owned_by(owner) + .await + .expect("list owned communities"); + + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].id, community_a); + } + async fn insert_channel(pool: &PgPool, community_id: Uuid, channel_id: Uuid) { let creator: Vec = vec![0u8; 32]; sqlx::query( diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 69cb48df89..1d8c961a29 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -12,10 +12,81 @@ static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations"); /// Run all pending Buzz database migrations. pub async fn run_migrations(pool: &PgPool) -> Result<()> { + reject_legacy_nip_rs_cardinality_ambiguity(pool).await?; MIGRATOR.run(pool).await?; Ok(()) } +/// Migration 0007 is checksum-frozen and predates exact NIP-RS tag-cardinality +/// enforcement. A populated database still on 0001-0006 must not let 0007 +/// irreversibly purge duplicate-tag history. Fail before sqlx starts its +/// migration transaction so an operator can inspect and repair those rows. +async fn reject_legacy_nip_rs_cardinality_ambiguity(pool: &PgPool) -> Result<()> { + let migrations_table: Option = + sqlx::query_scalar("SELECT to_regclass('_sqlx_migrations')::text") + .fetch_one(pool) + .await?; + if migrations_table.is_none() { + return Ok(()); + } + let applied: Option = + sqlx::query_scalar("SELECT max(version) FROM _sqlx_migrations WHERE success") + .fetch_one(pool) + .await?; + if applied.is_none_or(|version| version >= 7) { + return Ok(()); + } + + let ambiguous: bool = sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 FROM events e \ + WHERE e.kind = 30078 \ + AND e.d_tag ~ '^read-state:[0-9a-f]{32}$' \ + AND (\ + jsonb_typeof(e.tags) IS DISTINCT FROM 'array' \ + OR (\ + EXISTS (\ + SELECT 1 FROM jsonb_array_elements(\ + CASE WHEN jsonb_typeof(e.tags) = 'array' THEN e.tags ELSE '[]'::jsonb END\ + ) tag \ + WHERE tag = '[\"t\", \"read-state\"]'::jsonb\ + ) \ + AND (\ + (SELECT count(*) FROM jsonb_array_elements(\ + CASE WHEN jsonb_typeof(e.tags) = 'array' THEN e.tags ELSE '[]'::jsonb END\ + ) tag \ + WHERE jsonb_typeof(tag) = 'array' \ + AND tag->0 = '\"d\"'::jsonb) <> 1 \ + OR NOT EXISTS (\ + SELECT 1 FROM jsonb_array_elements(\ + CASE WHEN jsonb_typeof(e.tags) = 'array' THEN e.tags ELSE '[]'::jsonb END\ + ) tag \ + WHERE jsonb_typeof(tag) = 'array' \ + AND jsonb_array_length(tag) >= 2 \ + AND jsonb_typeof(tag->1) = 'string' \ + AND tag->>0 = 'd' \ + AND tag->>1 = e.d_tag\ + ) \ + OR (SELECT count(*) FROM jsonb_array_elements(\ + CASE WHEN jsonb_typeof(e.tags) = 'array' THEN e.tags ELSE '[]'::jsonb END\ + ) tag WHERE tag = '[\"t\", \"read-state\"]'::jsonb) <> 1\ + )\ + )\ + )\ + )", + ) + .fetch_one(pool) + .await?; + + if ambiguous { + return Err(crate::DbError::InvalidData( + "NIP-RS migration blocked: pre-0007 database contains kind-30078 rows with ambiguous d/t tag cardinality; repair or remove those nonconforming rows before retrying" + .into(), + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -261,6 +332,13 @@ mod tests { "communities", "rate_limit_violations", "_operator_global_tables", + "push_gateway_challenges", + "push_gateway_installations", + "push_gateway_delegations", + "push_gateway_endpoint_quotas", + "push_gateway_delivery_auth_replays", + "push_gateway_delivery_request_replays", + "product_feedback", ] { if normalized[insert_pos..].contains(&format!("'{value}'")) { globals.insert(value.to_owned()); @@ -471,7 +549,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 5); + assert_eq!(migrations.len(), 19); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -532,6 +610,208 @@ mod tests { assert!(migrations[4].sql.as_str().contains("search_tsv")); assert!(migrations[4].sql.as_str().contains("44200")); assert!(!migrations[0].sql.as_str().contains("44200")); + + // Community moderation (reports/bans/audit): additive migration, never + // folded into 0001 — same brownfield checksum rule as above. + assert_eq!(migrations[5].version, 6); + assert!(migrations[5] + .sql + .as_str() + .contains("CREATE TABLE moderation_reports")); + assert!(migrations[5] + .sql + .as_str() + .contains("CREATE TABLE community_bans")); + assert!(migrations[5] + .sql + .as_str() + .contains("CREATE TABLE moderation_actions")); + for action in crate::moderation::MODERATION_ACTION_CHECK_VOCAB { + assert!( + migrations[5].sql.as_str().contains(&format!("'{action}'")), + "migration 0006 moderation_actions.action CHECK must allow {action}" + ); + } + assert!(!migrations[0].sql.as_str().contains("moderation_reports")); + // NIP-RS retention is additive and boot-safe: seed replay watermarks + // before deleting payload history, without rewriting search storage. + assert_eq!(migrations[6].version, 7); + assert!(migrations[6] + .sql + .as_str() + .contains("LOCK TABLE events IN SHARE ROW EXCLUSIVE MODE")); + assert!(migrations[6] + .sql + .as_str() + .contains("CREATE TABLE parameterized_event_watermarks")); + assert!(migrations[6] + .sql + .as_str() + .contains("INSERT INTO parameterized_event_watermarks")); + assert!(migrations[6] + .sql + .as_str() + .contains("CREATE INDEX idx_event_mentions_community_event")); + assert!(migrations[6] + .sql + .as_str() + .contains("NIP-RS retention blocked: deleted event outranks live head")); + assert!(migrations[6] + .sql + .as_str() + .contains("DELETE FROM events old")); + assert!(!migrations[6] + .sql + .as_str() + .contains("ALTER TABLE events DROP COLUMN search_tsv")); + + // Fresh installs opt into the positive search allowlist without making + // populated databases rewrite their events heap during relay startup. + assert_eq!(migrations[7].version, 8); + assert!(migrations[7] + .sql + .as_str() + .contains("IF NOT EXISTS (SELECT 1 FROM events LIMIT 1)")); + assert!(migrations[7] + .sql + .as_str() + .contains("CASE WHEN kind IN (0, 9, 40002, 45001, 45003)")); + assert!(migrations[7].sql.as_str().contains("ELSE NULL::tsvector")); + + // Mixed-version guards are additive because 0007/0008 may already be + // recorded by a running relay and their sqlx checksums are immutable. + assert_eq!(migrations[8].version, 9); + assert!(migrations[8] + .sql + .as_str() + .contains("CREATE TRIGGER trg_events_nip_rs_watermark")); + assert!(migrations[8] + .sql + .as_str() + .contains("stale NIP-RS event rejected by durable watermark")); + assert!(migrations[8] + .sql + .as_str() + .contains("CREATE TRIGGER trg_events_purge_soft_deleted_nip_rs")); + assert!(migrations[8] + .sql + .as_str() + .contains("CREATE TRIGGER trg_event_mentions_require_live_event")); + + assert_eq!(migrations[9].version, 10); + assert!(migrations[9] + .sql + .as_str() + .contains("CREATE OR REPLACE FUNCTION guard_nip_rs_watermark")); + assert!(migrations[9].sql.as_str().contains("RETURN NULL")); + + assert_eq!(migrations[10].version, 11); + assert!(migrations[10] + .sql + .as_str() + .contains("CREATE OR REPLACE FUNCTION guard_nip_rs_watermark")); + assert!(migrations[10] + .sql + .as_str() + .contains("CREATE OR REPLACE FUNCTION purge_soft_deleted_nip_rs")); + assert!(migrations[10].sql.as_str().contains("tag->>0 = 'd'")); + assert!(migrations[10].sql.as_str().contains(") = 1")); + + // Push leases and their durable outbox are relay-owned and structurally + // community-scoped; the public gateway remains stateless. + assert_eq!(migrations[11].version, 12); + assert!(migrations[11] + .sql + .as_str() + .contains("CREATE TABLE push_leases")); + assert!(migrations[11] + .sql + .as_str() + .contains("CREATE TABLE push_wake_outbox")); + assert!(migrations[11] + .sql + .as_str() + .contains("PRIMARY KEY (community_id, author, installation_id)")); + assert!(!migrations[0].sql.as_str().contains("push_leases")); + + assert_eq!(migrations[12].version, 13); + assert!(migrations[12] + .sql + .as_str() + .contains("ADD COLUMN endpoint_enabled")); + + // Kind 30350 is author-only encrypted data, so its ciphertext is never + // indexed for NIP-50 search. Preserve the 0001 checksum and extend the + // generated expression additively. + assert_eq!(migrations[13].version, 14); + assert!(migrations[13].sql.as_str().contains("30350")); + assert!(migrations[13].sql.as_str().contains("search_tsv")); + assert!(!migrations[0].sql.as_str().contains("30350")); + + // Public push-gateway authority is intentionally deployment-global and + // durable: immediate revocation and hostile-relay admission cannot be + // honestly provided by a stateless gateway. + assert_eq!(migrations[14].version, 15); + assert!(migrations[14] + .sql + .as_str() + .contains("CREATE TABLE push_gateway_installations")); + assert!(migrations[14] + .sql + .as_str() + .contains("push_gateway_delegations")); + assert!(migrations[14] + .sql + .as_str() + .contains("_operator_global_tables")); + + // Community archival and product feedback landed concurrently. Keep + // both additive migrations in a single, unambiguous sequence. + assert_eq!(migrations[15].version, 16); + assert!(migrations[15] + .sql + .as_str() + .contains("ADD COLUMN archived_at")); + + // Product feedback is a deployment-private sidecar; community_id is + // provenance, not an operator-review authorization boundary. + assert_eq!(migrations[16].version, 17); + assert!(migrations[16] + .sql + .as_str() + .contains("CREATE TABLE product_feedback")); + assert!(migrations[16] + .sql + .as_str() + .contains("community_id UUID NOT NULL")); + assert!(migrations[16] + .sql + .as_str() + .contains("('product_feedback', 'deployment product inbox")); + assert!(!migrations[0].sql.as_str().contains("product_feedback")); + + // Matching is driven from a parent-table trigger so all partition and + // internal insertion paths share the same crash-safe allowlist seam. + assert_eq!(migrations[17].version, 18); + let matcher = migrations[17].sql.as_str(); + assert!(matcher.contains("CREATE TABLE push_match_queue")); + assert!(matcher.contains("AFTER INSERT ON events")); + assert!(matcher.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)")); + assert!(!migrations[0].sql.as_str().contains("push_match_queue")); + + // Mesh status is a heartbeat, not an audit stream. The additive + // migration removes accumulated soft-deleted payloads and covers old + // writers during rolling deploys without changing kind:30003 broadly. + assert_eq!(migrations[18].version, 19); + let mesh_retention = migrations[18].sql.as_str(); + assert!(mesh_retention.contains("buzz-mesh-member-status:%")); + assert!(mesh_retention.contains("buzz-mesh-status")); + assert!(mesh_retention + .contains("CREATE TRIGGER trg_events_purge_soft_deleted_buzz_mesh_status")); + assert!(!migrations[0] + .sql + .as_str() + .contains("purge_soft_deleted_buzz_mesh_status")); } #[test] @@ -707,6 +987,136 @@ mod tests { .expect("read applied migrations") } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn pre_0007_ambiguous_nip_rs_data_blocks_without_mutation_and_allows_retry() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(6, &pool) + .await + .expect("apply migrations 1-6"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("pre-0007-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + let event_id = vec![1_u8; 32]; + let pubkey = vec![2_u8; 32]; + let d_tag = format!("read-state:{}", "a".repeat(32)); + let ambiguous_tags = serde_json::json!([["d", d_tag], ["d", "other"], ["t", "read-state"]]); + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, d_tag) \ + VALUES ($1, $2, $3, NOW(), 30078, $4, 'ambiguous', $5, NOW(), $6)", + ) + .bind(community_id) + .bind(&event_id) + .bind(&pubkey) + .bind(&ambiguous_tags) + .bind(vec![3_u8; 64]) + .bind(&d_tag) + .execute(&pool) + .await + .expect("insert ambiguous NIP-RS row"); + + let before_versions = applied_versions(&pool).await; + let before_row: (serde_json::Value, String) = + sqlx::query_as("SELECT tags, content FROM events WHERE community_id=$1 AND id=$2") + .bind(community_id) + .bind(&event_id) + .fetch_one(&pool) + .await + .expect("read ambiguous row before blocked migration"); + let blocked = run_migrations(&pool).await; + assert!(blocked.is_err(), "ambiguous pre-0007 data must fail closed"); + assert_eq!(applied_versions(&pool).await, before_versions); + let after_row: (serde_json::Value, String) = + sqlx::query_as("SELECT tags, content FROM events WHERE community_id=$1 AND id=$2") + .bind(community_id) + .bind(&event_id) + .fetch_one(&pool) + .await + .expect("blocked migration must preserve source row"); + assert_eq!(after_row, before_row); + + let repaired_tags = serde_json::json!([["d", d_tag], ["t", "read-state"]]); + sqlx::query("UPDATE events SET tags=$1 WHERE community_id=$2 AND id=$3") + .bind(repaired_tags) + .bind(community_id) + .bind(&event_id) + .execute(&pool) + .await + .expect("repair ambiguous row"); + run_migrations(&pool) + .await + .expect("retry succeeds after operator repair"); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(19)); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn populated_upgrade_preserves_search_policy_except_for_push_leases() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(7, &pool) + .await + .expect("apply migrations 1-7"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("pre-0008-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + for (marker, kind) in [(1_u8, 1_i32), (2_u8, 30_350_i32)] { + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at) \ + VALUES ($1, $2, $3, NOW(), $4, '[]'::jsonb, 'brownfield needle', $5, NOW())", + ) + .bind(community_id) + .bind(vec![marker; 32]) + .bind(vec![marker + 10; 32]) + .bind(kind) + .bind(vec![marker + 20; 64]) + .execute(&pool) + .await + .expect("insert brownfield event"); + } + + MIGRATOR + .run_to(11, &pool) + .await + .expect("apply main migrations through 11"); + let before: Vec<(i32, bool)> = sqlx::query_as( + "SELECT kind, search_tsv @@ plainto_tsquery('simple', 'needle') \ + FROM events ORDER BY kind", + ) + .fetch_all(&pool) + .await + .expect("read pre-push search behavior"); + assert_eq!(before, vec![(1, true), (30_350, true)]); + + run_migrations(&pool) + .await + .expect("apply push migrations to populated database"); + let after: Vec<(i32, Option)> = sqlx::query_as( + "SELECT kind, search_tsv @@ plainto_tsquery('simple', 'needle') \ + FROM events ORDER BY kind", + ) + .fetch_all(&pool) + .await + .expect("read post-push search behavior"); + assert_eq!(after, vec![(1, Some(true)), (30_350, None)]); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() { @@ -715,7 +1125,15 @@ mod tests { run_migrations(&pool).await.expect("run migrations"); - assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]); + // Every embedded migration must apply, in order. Derive the expected + // list from the MIGRATOR itself so this doesn't go stale as additive + // migrations land (it previously hardcoded [1, 2, 3] and rotted). + let expected: Vec = { + let mut versions: Vec = MIGRATOR.iter().map(|m| m.version).collect(); + versions.sort_unstable(); + versions + }; + assert_eq!(applied_versions(&pool).await, expected); let sql = migration_sql(); let tables = create_tables(sql.as_str()); for table in [ @@ -738,5 +1156,25 @@ mod tests { ); assert!(exists, "migration should create {table}"); } + + let search_expression: String = sqlx::query_scalar( + "SELECT pg_get_expr(adbin, adrelid) \ + FROM pg_attrdef \ + WHERE adrelid = 'events'::regclass \ + AND adnum = (SELECT attnum FROM pg_attribute \ + WHERE attrelid = 'events'::regclass \ + AND attname = 'search_tsv')", + ) + .fetch_one(&pool) + .await + .expect("read fresh-install search expression"); + assert!( + search_expression.contains("ARRAY[0, 9, 40002, 45001, 45003]"), + "fresh-install search allowlist has the wrong kinds: {search_expression}" + ); + assert!( + search_expression.contains("ELSE NULL::tsvector"), + "fresh installs must default non-allowlisted kinds to NULL: {search_expression}" + ); } } diff --git a/crates/buzz-db/src/moderation.rs b/crates/buzz-db/src/moderation.rs new file mode 100644 index 0000000000..be8b712d45 --- /dev/null +++ b/crates/buzz-db/src/moderation.rs @@ -0,0 +1,894 @@ +//! Community moderation persistence (Phase 1 contract). +//! +//! Backs the NIP-56 report queue (`moderation_reports`), ban/timeout state +//! (`community_bans`), and the moderation audit trail (`moderation_actions`) +//! from `migrations/0006_moderation.sql`. +//! +//! ## Tenant invariant +//! Every function takes a [`CommunityId`] and touches exactly one community's +//! rows. Report/ban targets are resolved by callers under the requesting +//! `TenantContext` **before** they reach this module — no function here may +//! perform a cross-community or global lookup (MOD invariants, +//! `docs/spec/MultiTenantRelay.tla`). +//! +//! Lane ownership: L1 (Max). Signatures below are the contract; changes go +//! through the integration thread. + +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row as _}; +use uuid::Uuid; + +use crate::error::Result; +use crate::CommunityId; + +/// What a report points at. Exactly one target class per report row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReportTarget { + /// `e`-tag target: an event that must resolve inside the tenant. + Event(Vec), + /// `p`-only target: a community-local report about a pubkey. + Pubkey(Vec), + /// `x`-tag target: a media blob sha256, resolved via tenant-scoped refs. + Blob(Vec), +} + +/// Insert parameters for a new report row (from an accepted kind:1984 event). +#[derive(Debug, Clone)] +pub struct NewReport<'a> { + /// Signed kind:1984 event id (32 bytes) — idempotency key per community. + pub report_event_id: &'a [u8], + /// Reporter pubkey bytes. Mod-queue-visible; never revealed to the author. + pub reporter_pubkey: &'a [u8], + /// Resolved (in-tenant) target. + pub target: ReportTarget, + /// Channel inferred from an in-tenant target event row, when resolvable. + pub channel_id: Option, + /// NIP-56 report type (already validated by ingest). + pub report_type: &'a str, + /// Reporter's optional free-text note. + pub note: Option<&'a str>, +} + +/// A report row as read back for the moderation queue. +#[derive(Debug, Clone)] +pub struct ReportRecord { + /// Row id (unique within the community). + pub id: Uuid, + /// Signed kind:1984 event id. + pub report_event_id: Vec, + /// Reporter pubkey bytes. + pub reporter_pubkey: Vec, + /// Report target. + pub target: ReportTarget, + /// Inferred channel, if the target resolved to one. + pub channel_id: Option, + /// NIP-56 report type. + pub report_type: String, + /// Reporter's note. + pub note: Option, + /// `open` | `resolved` | `dismissed` | `escalated`. + pub status: String, + /// Resolving moderator, once resolved. + pub resolved_by: Option>, + /// Resolution timestamp. + pub resolved_at: Option>, + /// `moderation_actions` row that resolved this report. + pub action_id: Option, + /// Report creation time. + pub created_at: DateTime, +} + +/// Ban/timeout state for one member in one community. +#[derive(Debug, Clone)] +pub struct BanRecord { + /// Member pubkey bytes. + pub pubkey: Vec, + /// Whether the member is currently banned (check `ban_expires_at`). + pub banned: bool, + /// Ban expiry; `None` while `banned` ⇒ permanent. + pub ban_expires_at: Option>, + /// Moderator-supplied ban reason (private). + pub ban_reason: Option, + /// Write-block until this timestamp; `None` or past ⇒ not timed out. + pub muted_until: Option>, + /// Moderator-supplied timeout reason (private). + pub mute_reason: Option, + /// Moderator who last modified this row. + pub actor_pubkey: Vec, + /// Last modification time. + pub updated_at: DateTime, +} + +/// Audit action values accepted by the `moderation_actions.action` CHECK in +/// migration 0006. Keep this in lockstep with `migrations/0006_moderation.sql`. +pub const MODERATION_ACTION_CHECK_VOCAB: &[&str] = &[ + "delete_message", + "kick", + "ban", + "unban", + "timeout", + "untimeout", + "dismiss_report", + "escalate", + "resolve:delete", + "resolve:kick", + "resolve:ban", + "resolve:timeout", +]; + +/// Insert parameters for a moderation audit row. +#[derive(Debug, Clone)] +pub struct NewAction<'a> { + /// Acting moderator pubkey bytes. + pub actor_pubkey: &'a [u8], + /// `delete_message` | `kick` | `ban` | `unban` | `timeout` | `untimeout` + /// | `dismiss_report` | `escalate` | `resolve:*` decision rows (DB CHECK-enforced). + pub action: &'a str, + /// Actioned member, when the action targets a pubkey. + pub target_pubkey: Option<&'a [u8]>, + /// Actioned event, when the action targets an event. + pub target_event_id: Option<&'a [u8]>, + /// Channel context, when known. + pub channel_id: Option, + /// Machine-readable rule/reason code. + pub reason_code: Option<&'a str>, + /// Sanitized reason, safe for the public tombstone. + pub public_reason: Option<&'a str>, + /// Mod-only context; never leaves the audit surface. + pub private_reason: Option<&'a str>, + /// NIP-OA matched principal (`self` | `owner`) for ban enforcement audit. + pub matched_principal: Option<&'a str>, +} + +/// An audit row as read back for `buzz moderation audit`. +#[derive(Debug, Clone)] +pub struct ActionRecord { + /// Row id. + pub id: Uuid, + /// Acting moderator pubkey bytes. + pub actor_pubkey: Vec, + /// Action name. + pub action: String, + /// Actioned member. + pub target_pubkey: Option>, + /// Actioned event. + pub target_event_id: Option>, + /// Channel context. + pub channel_id: Option, + /// Machine-readable rule/reason code. + pub reason_code: Option, + /// Sanitized public reason. + pub public_reason: Option, + /// Mod-only reason. + pub private_reason: Option, + /// NIP-OA principal matched by enforcement, when relevant. + pub matched_principal: Option, + /// Action time. + pub created_at: DateTime, +} + +/// Insert a new report row. Idempotent on `(community, report_event_id)`: +/// re-ingesting the same signed report is a no-op returning the existing id. +pub async fn insert_report( + pool: &PgPool, + community: CommunityId, + report: NewReport<'_>, +) -> Result { + let (target_kind, target_event_id, target_pubkey, target_blob_sha256) = match &report.target { + ReportTarget::Event(id) => ("event", Some(id.as_slice()), None, None), + ReportTarget::Pubkey(pubkey) => ("pubkey", None, Some(pubkey.as_slice()), None), + ReportTarget::Blob(sha256) => ("blob", None, None, Some(sha256.as_slice())), + }; + + let row = sqlx::query( + r#" + INSERT INTO moderation_reports ( + community_id, report_event_id, reporter_pubkey, target_kind, + target_event_id, target_pubkey, target_blob_sha256, channel_id, + report_type, note + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (community_id, report_event_id) DO UPDATE SET + report_event_id = EXCLUDED.report_event_id + RETURNING id + "#, + ) + .bind(community.as_uuid()) + .bind(report.report_event_id) + .bind(report.reporter_pubkey) + .bind(target_kind) + .bind(target_event_id) + .bind(target_pubkey) + .bind(target_blob_sha256) + .bind(report.channel_id) + .bind(report.report_type) + .bind(report.note) + .fetch_one(pool) + .await?; + + Ok(row.try_get("id")?) +} + +/// List reports for the moderation queue, newest first. +/// `status = None` lists all; `Some("open")` etc. filters. +pub async fn list_reports( + pool: &PgPool, + community: CommunityId, + status: Option<&str>, + limit: i64, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id, + target_pubkey, target_blob_sha256, channel_id, report_type, note, + status, resolved_by, resolved_at, action_id, created_at + FROM moderation_reports + WHERE community_id = $1 AND ($2::text IS NULL OR status = $2) + ORDER BY created_at DESC + LIMIT $3 + "#, + ) + .bind(community.as_uuid()) + .bind(status) + .bind(limit) + .fetch_all(pool) + .await?; + + rows.into_iter().map(row_to_report).collect() +} + +/// Fetch one report by row id. +pub async fn get_report( + pool: &PgPool, + community: CommunityId, + report_id: Uuid, +) -> Result> { + let row = sqlx::query( + r#" + SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id, + target_pubkey, target_blob_sha256, channel_id, report_type, note, + status, resolved_by, resolved_at, action_id, created_at + FROM moderation_reports + WHERE community_id = $1 AND id = $2 + "#, + ) + .bind(community.as_uuid()) + .bind(report_id) + .fetch_optional(pool) + .await?; + + row.map(row_to_report).transpose() +} + +/// Fetch one report by signed NIP-56 report event id. +pub async fn get_report_by_event( + pool: &PgPool, + community: CommunityId, + report_event_id: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id, + target_pubkey, target_blob_sha256, channel_id, report_type, note, + status, resolved_by, resolved_at, action_id, created_at + FROM moderation_reports + WHERE community_id = $1 AND report_event_id = $2 + "#, + ) + .bind(community.as_uuid()) + .bind(report_event_id) + .fetch_optional(pool) + .await?; + + row.map(row_to_report).transpose() +} + +/// Mark a report resolved/dismissed/escalated, linking the audit action. +/// Returns `false` if the report was not found or already closed. +pub async fn resolve_report( + pool: &PgPool, + community: CommunityId, + report_id: Uuid, + status: &str, + resolved_by: &[u8], + action_id: Option, +) -> Result { + let result = sqlx::query( + r#" + UPDATE moderation_reports + SET status = $3, resolved_by = $4, resolved_at = now(), action_id = $5 + WHERE community_id = $1 AND id = $2 AND status = 'open' + "#, + ) + .bind(community.as_uuid()) + .bind(report_id) + .bind(status) + .bind(resolved_by) + .bind(action_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Upsert a ban: sets `banned = true` with optional expiry + reason. +pub async fn ban_member( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + reason: Option<&str>, + expires_at: Option>, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO community_bans ( + community_id, pubkey, banned, ban_expires_at, ban_reason, actor_pubkey + ) VALUES ($1, $2, true, $3, $4, $5) + ON CONFLICT (community_id, pubkey) DO UPDATE SET + banned = true, + ban_expires_at = EXCLUDED.ban_expires_at, + ban_reason = EXCLUDED.ban_reason, + actor_pubkey = EXCLUDED.actor_pubkey, + updated_at = now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(expires_at) + .bind(reason) + .bind(actor) + .execute(pool) + .await?; + + Ok(()) +} + +/// Lift a ban. Returns `false` if the member was not banned. +pub async fn unban_member( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], +) -> Result { + let result = sqlx::query( + r#" + UPDATE community_bans + SET banned = false, ban_expires_at = NULL, ban_reason = NULL, + actor_pubkey = $3, updated_at = now() + WHERE community_id = $1 AND pubkey = $2 AND banned = true + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(actor) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Upsert a timeout: sets `muted_until` + reason. +pub async fn timeout_member( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + muted_until: DateTime, + reason: Option<&str>, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO community_bans ( + community_id, pubkey, muted_until, mute_reason, actor_pubkey + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (community_id, pubkey) DO UPDATE SET + muted_until = EXCLUDED.muted_until, + mute_reason = EXCLUDED.mute_reason, + actor_pubkey = EXCLUDED.actor_pubkey, + updated_at = now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(muted_until) + .bind(reason) + .bind(actor) + .execute(pool) + .await?; + + Ok(()) +} + +/// Clear a timeout early. Returns `false` if the member was not timed out. +pub async fn untimeout_member( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], +) -> Result { + let result = sqlx::query( + r#" + UPDATE community_bans + SET muted_until = NULL, mute_reason = NULL, + actor_pubkey = $3, updated_at = now() + WHERE community_id = $1 AND pubkey = $2 AND muted_until > now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(actor) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Restriction snapshot consumed by the auth-seam gate (L4) and write gates. +/// +/// One cheap read per check: `banned` already accounts for expiry; +/// `muted_until` is returned raw so the caller can render the timestamp in +/// the `restricted:` message. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RestrictionState { + /// Currently banned (row exists, `banned`, unexpired). + pub banned: bool, + /// Active timeout expiry, if in the future. + pub muted_until: Option>, +} + +/// Fetch the current restriction state for a pubkey in one community. +/// Missing row ⇒ `RestrictionState::default()` (unrestricted). +pub async fn restriction_state( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], +) -> Result { + let row = sqlx::query( + r#" + SELECT + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned, + CASE WHEN muted_until > now() THEN muted_until ELSE NULL END AS muted_until + FROM community_bans + WHERE community_id = $1 AND pubkey = $2 + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(pool) + .await?; + + match row { + Some(row) => Ok(RestrictionState { + banned: row.try_get("banned")?, + muted_until: row.try_get("muted_until")?, + }), + None => Ok(RestrictionState::default()), + } +} + +/// Fetch the full ban/timeout row (moderation queue / audit views). +pub async fn get_ban( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT pubkey, + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned, + ban_expires_at, ban_reason, muted_until, + mute_reason, actor_pubkey, updated_at + FROM community_bans + WHERE community_id = $1 AND pubkey = $2 + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(pool) + .await?; + + row.map(row_to_ban).transpose() +} + +/// List currently-restricted members (active ban or timeout) for the queue. +pub async fn list_restricted(pool: &PgPool, community: CommunityId) -> Result> { + let rows = sqlx::query( + r#" + SELECT pubkey, + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned, + ban_expires_at, ban_reason, muted_until, + mute_reason, actor_pubkey, updated_at + FROM community_bans + WHERE community_id = $1 + AND ( + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) + OR muted_until > now() + ) + ORDER BY updated_at DESC + "#, + ) + .bind(community.as_uuid()) + .fetch_all(pool) + .await?; + + rows.into_iter().map(row_to_ban).collect() +} + +/// Insert a moderation audit row, returning its id. +pub async fn insert_action( + pool: &PgPool, + community: CommunityId, + action: NewAction<'_>, +) -> Result { + let row = sqlx::query( + r#" + INSERT INTO moderation_actions ( + community_id, actor_pubkey, action, target_pubkey, target_event_id, + channel_id, reason_code, public_reason, private_reason, matched_principal + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + "#, + ) + .bind(community.as_uuid()) + .bind(action.actor_pubkey) + .bind(action.action) + .bind(action.target_pubkey) + .bind(action.target_event_id) + .bind(action.channel_id) + .bind(action.reason_code) + .bind(action.public_reason) + .bind(action.private_reason) + .bind(action.matched_principal) + .fetch_one(pool) + .await?; + + Ok(row.try_get("id")?) +} + +/// List audit rows, newest first (`buzz moderation audit`). +pub async fn list_actions( + pool: &PgPool, + community: CommunityId, + limit: i64, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, actor_pubkey, action, target_pubkey, target_event_id, channel_id, + reason_code, public_reason, private_reason, matched_principal, created_at + FROM moderation_actions + WHERE community_id = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(community.as_uuid()) + .bind(limit) + .fetch_all(pool) + .await?; + + rows.into_iter().map(row_to_action).collect() +} + +fn row_to_report(row: sqlx::postgres::PgRow) -> Result { + let target_kind: String = row.try_get("target_kind")?; + let target = match target_kind.as_str() { + "event" => ReportTarget::Event(row.try_get("target_event_id")?), + "pubkey" => ReportTarget::Pubkey(row.try_get("target_pubkey")?), + "blob" => ReportTarget::Blob(row.try_get("target_blob_sha256")?), + other => { + return Err(crate::error::DbError::InvalidData(format!( + "invalid report target_kind: {other}" + ))) + } + }; + + Ok(ReportRecord { + id: row.try_get("id")?, + report_event_id: row.try_get("report_event_id")?, + reporter_pubkey: row.try_get("reporter_pubkey")?, + target, + channel_id: row.try_get("channel_id")?, + report_type: row.try_get("report_type")?, + note: row.try_get("note")?, + status: row.try_get("status")?, + resolved_by: row.try_get("resolved_by")?, + resolved_at: row.try_get("resolved_at")?, + action_id: row.try_get("action_id")?, + created_at: row.try_get("created_at")?, + }) +} + +fn row_to_ban(row: sqlx::postgres::PgRow) -> Result { + Ok(BanRecord { + pubkey: row.try_get("pubkey")?, + banned: row.try_get("banned")?, + ban_expires_at: row.try_get("ban_expires_at")?, + ban_reason: row.try_get("ban_reason")?, + muted_until: row.try_get("muted_until")?, + mute_reason: row.try_get("mute_reason")?, + actor_pubkey: row.try_get("actor_pubkey")?, + updated_at: row.try_get("updated_at")?, + }) +} + +fn row_to_action(row: sqlx::postgres::PgRow) -> Result { + Ok(ActionRecord { + id: row.try_get("id")?, + actor_pubkey: row.try_get("actor_pubkey")?, + action: row.try_get("action")?, + target_pubkey: row.try_get("target_pubkey")?, + target_event_id: row.try_get("target_event_id")?, + channel_id: row.try_get("channel_id")?, + reason_code: row.try_get("reason_code")?, + public_reason: row.try_get("public_reason")?, + private_reason: row.try_get("private_reason")?, + matched_principal: row.try_get("matched_principal")?, + created_at: row.try_get("created_at")?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + let host = format!("moderation-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + fn random_32() -> Vec { + let mut bytes = Vec::with_capacity(32); + bytes.extend_from_slice(Uuid::new_v4().as_bytes()); + bytes.extend_from_slice(Uuid::new_v4().as_bytes()); + bytes + } + + fn new_report<'a>( + report_event_id: &'a [u8], + reporter_pubkey: &'a [u8], + target_event_id: &'a [u8], + note: Option<&'a str>, + ) -> NewReport<'a> { + NewReport { + report_event_id, + reporter_pubkey, + target: ReportTarget::Event(target_event_id.to_vec()), + channel_id: None, + report_type: "spam", + note, + } + } + + /// Community moderation restrictions are tenant-scoped. This guards the same + /// mutation class as the TLA⁺ tenant-fence invariant: a ban in community A + /// must not restrict the same pubkey in community B, through either the hot + /// `restriction_state` read or the queue-facing `list_restricted` read. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn restrictions_are_confined_to_their_community() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let pubkey = random_32(); + let actor = random_32(); + + ban_member( + &pool, + community_a, + &pubkey, + &actor, + Some("tenant fence test"), + None, + ) + .await + .expect("ban in community A"); + + let state_a = restriction_state(&pool, community_a, &pubkey) + .await + .expect("restriction_state A"); + assert!(state_a.banned, "pubkey must be banned in community A"); + + let state_b = restriction_state(&pool, community_b, &pubkey) + .await + .expect("restriction_state B"); + assert!( + !state_b.banned && state_b.muted_until.is_none(), + "ban in A must not restrict the same pubkey in community B" + ); + + let restricted_a = list_restricted(&pool, community_a) + .await + .expect("list restricted A"); + assert!( + restricted_a.iter().any(|row| row.pubkey == pubkey), + "community A restricted list must include the banned pubkey" + ); + + let restricted_b = list_restricted(&pool, community_b) + .await + .expect("list restricted B"); + assert!( + restricted_b.iter().all(|row| row.pubkey != pubkey), + "community B restricted list must not include community A's ban" + ); + } + + /// Ban expiry is evaluated in SQL, while a live timeout on the same row keeps + /// the member restricted for writes. This protects the one-row/two-restriction + /// shape used by L4's auth and ingest gates. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expired_ban_does_not_hide_active_timeout() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let pubkey = random_32(); + let actor = random_32(); + + ban_member( + &pool, + community, + &pubkey, + &actor, + Some("expired ban"), + Some(Utc::now() - Duration::hours(1)), + ) + .await + .expect("insert expired ban"); + timeout_member( + &pool, + community, + &pubkey, + &actor, + Utc::now() + Duration::hours(1), + Some("active timeout"), + ) + .await + .expect("insert active timeout"); + + let state = restriction_state(&pool, community, &pubkey) + .await + .expect("restriction_state"); + assert!(!state.banned, "expired ban must evaluate inactive"); + assert!( + state.muted_until.is_some(), + "active timeout must survive an expired ban on the same row" + ); + + let ban = get_ban(&pool, community, &pubkey) + .await + .expect("get ban") + .expect("restriction row exists"); + assert!( + !ban.banned, + "get_ban must also evaluate expired ban inactive" + ); + assert!(ban.muted_until.is_some(), "get_ban must preserve timeout"); + + let restricted = list_restricted(&pool, community) + .await + .expect("list restricted"); + let listed = restricted + .iter() + .find(|row| row.pubkey == pubkey) + .expect("timeout-only row remains listed"); + assert!( + !listed.banned, + "list_restricted reports expired ban inactive" + ); + assert!( + listed.muted_until.is_some(), + "list_restricted preserves timeout" + ); + } + + /// Re-ingesting the same signed report is idempotent by event id and must not + /// reopen or otherwise reset a report that a moderator already resolved. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_reingest_returns_same_id_and_preserves_resolution() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let report_event_id = random_32(); + let reporter = random_32(); + let target_event_id = random_32(); + let resolver = random_32(); + + let report = new_report(&report_event_id, &reporter, &target_event_id, Some("first")); + let first_id = insert_report(&pool, community, report) + .await + .expect("insert report"); + + assert!( + resolve_report(&pool, community, first_id, "resolved", &resolver, None) + .await + .expect("resolve report"), + "first resolve should close the report" + ); + + let duplicate = new_report(&report_event_id, &reporter, &target_event_id, Some("retry")); + let second_id = insert_report(&pool, community, duplicate) + .await + .expect("re-ingest report"); + assert_eq!(first_id, second_id, "re-ingest must return the same row id"); + + let row = get_report(&pool, community, first_id) + .await + .expect("get report") + .expect("report exists"); + let row_by_event = get_report_by_event(&pool, community, &report_event_id) + .await + .expect("get report by event id") + .expect("report exists by event id"); + assert_eq!( + row_by_event.id, first_id, + "report event id lookup must return the same row" + ); + assert_eq!( + row.status, "resolved", + "re-ingest must not reopen the report" + ); + assert!( + row.resolved_at.is_some(), + "resolution timestamp is preserved" + ); + assert_eq!( + row.resolved_by.as_deref(), + Some(resolver.as_slice()), + "resolving moderator is preserved" + ); + } + + /// `resolve_report` is a guarded transition out of `open`; a second resolve + /// on a closed report must be a no-op and return `false`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn resolve_report_returns_false_after_report_is_closed() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let report_event_id = random_32(); + let reporter = random_32(); + let target_event_id = random_32(); + let resolver = random_32(); + + let report_id = insert_report( + &pool, + community, + new_report(&report_event_id, &reporter, &target_event_id, None), + ) + .await + .expect("insert report"); + + assert!( + resolve_report(&pool, community, report_id, "dismissed", &resolver, None) + .await + .expect("first resolve"), + "first resolve should update the open report" + ); + assert!( + !resolve_report(&pool, community, report_id, "resolved", &resolver, None) + .await + .expect("second resolve"), + "second resolve should return false once the report is closed" + ); + } +} diff --git a/crates/buzz-db/src/product_feedback.rs b/crates/buzz-db/src/product_feedback.rs new file mode 100644 index 0000000000..1a9f45e62b --- /dev/null +++ b/crates/buzz-db/src/product_feedback.rs @@ -0,0 +1,188 @@ +//! Persistence for deployment-level Buzz product feedback. +//! +//! Feedback retains its source [`CommunityId`] as provenance, but is not a +//! community moderation concern and is never inserted into the events table. + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::{PgPool, Row as _}; +use uuid::Uuid; + +use crate::{error::Result, CommunityId}; + +/// Validated fields from an accepted product-feedback event. +#[derive(Debug, Clone)] +pub struct NewProductFeedback<'a> { + /// Signed feedback event id (32 bytes), used for idempotency. + pub event_id: &'a [u8], + /// Authenticated submitter's Nostr pubkey (32 bytes). + pub submitter_pubkey: &'a [u8], + /// Optional category from the relay-validated vocabulary. + pub category: Option<&'a str>, + /// Required free-text feedback body. + pub body: &'a str, + /// Full validated event tags (attachments and diagnostics metadata included). + pub tags: &'a serde_json::Value, + /// Timestamp signed into the source event. + pub event_created_at: DateTime, +} + +/// Product-feedback row returned to deployment-operator tooling. +#[derive(Debug, Clone, Serialize)] +pub struct ProductFeedbackRecord { + /// Sidecar row id. + pub id: Uuid, + /// Source community, retained as provenance only. + pub community_id: Uuid, + /// Signed source event id. + pub event_id: String, + /// Signed submitter pubkey. + pub submitter_pubkey: String, + /// Optional feedback category. + pub category: Option, + /// Feedback body. + pub body: String, + /// Full source tags, including attachment and diagnostics metadata. + pub tags: serde_json::Value, + /// Timestamp signed into the source event. + pub event_created_at: DateTime, + /// Time accepted by this deployment. + pub received_at: DateTime, +} + +/// Insert product feedback, idempotent deployment-wide by signed event id. +/// +/// The first accepted submission owns the provenance row. Replaying the exact +/// same signed event through another community returns the same row without +/// changing its source community; callers intentionally receive the same +/// successful acknowledgment in both cases. +pub async fn insert( + pool: &PgPool, + community: CommunityId, + feedback: NewProductFeedback<'_>, +) -> Result { + let row = sqlx::query( + r#" + INSERT INTO product_feedback ( + community_id, event_id, submitter_pubkey, category, body, tags, + event_created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (event_id) DO UPDATE SET + event_id = EXCLUDED.event_id + RETURNING id + "#, + ) + .bind(community.as_uuid()) + .bind(feedback.event_id) + .bind(feedback.submitter_pubkey) + .bind(feedback.category) + .bind(feedback.body) + .bind(feedback.tags) + .bind(feedback.event_created_at) + .fetch_one(pool) + .await?; + + Ok(row.try_get("id")?) +} + +/// List feedback across all communities, newest received first. +pub async fn list(pool: &PgPool, limit: i64) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, community_id, event_id, submitter_pubkey, category, body, + tags, event_created_at, received_at + FROM product_feedback + ORDER BY received_at DESC, id + LIMIT $1 + "#, + ) + .bind(limit) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|row| { + Ok(ProductFeedbackRecord { + id: row.try_get("id")?, + community_id: row.try_get("community_id")?, + event_id: hex::encode(row.try_get::, _>("event_id")?), + submitter_pubkey: hex::encode(row.try_get::, _>("submitter_pubkey")?), + category: row.try_get("category")?, + body: row.try_get("body")?, + tags: row.try_get("tags")?, + event_created_at: row.try_get("event_created_at")?, + received_at: row.try_get("received_at")?, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn duplicate_event_keeps_first_community_provenance() { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .expect("BUZZ_TEST_DATABASE_URL or DATABASE_URL"); + let pool = PgPool::connect(&database_url) + .await + .expect("connect test DB"); + let first = Uuid::new_v4(); + let second = Uuid::new_v4(); + for (id, host) in [ + (first, format!("feedback-first-{first}.test")), + (second, format!("feedback-second-{second}.test")), + ] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(&pool) + .await + .expect("insert community"); + } + + let mut event_id = [0_u8; 32]; + event_id[..16].copy_from_slice(Uuid::new_v4().as_bytes()); + event_id[16..].copy_from_slice(Uuid::new_v4().as_bytes()); + let pubkey = [7_u8; 32]; + let tags = serde_json::json!([["category", "bug"]]); + let feedback = || NewProductFeedback { + event_id: &event_id, + submitter_pubkey: &pubkey, + category: Some("bug"), + body: "same signed feedback", + tags: &tags, + event_created_at: Utc::now(), + }; + + let first_row = insert(&pool, CommunityId::from_uuid(first), feedback()) + .await + .expect("first insert"); + let duplicate_row = insert(&pool, CommunityId::from_uuid(second), feedback()) + .await + .expect("duplicate insert"); + assert_eq!(duplicate_row, first_row); + + let rows: Vec = + sqlx::query_scalar("SELECT community_id FROM product_feedback WHERE event_id = $1") + .bind(event_id.as_slice()) + .fetch_all(&pool) + .await + .expect("read feedback provenance"); + assert_eq!(rows, vec![first]); + + sqlx::query("DELETE FROM product_feedback WHERE event_id = $1") + .bind(event_id.as_slice()) + .execute(&pool) + .await + .expect("delete feedback"); + sqlx::query("DELETE FROM communities WHERE id = ANY($1)") + .bind(vec![first, second]) + .execute(&pool) + .await + .expect("delete communities"); + } +} diff --git a/crates/buzz-db/src/push.rs b/crates/buzz-db/src/push.rs new file mode 100644 index 0000000000..dc5920e1e9 --- /dev/null +++ b/crates/buzz-db/src/push.rs @@ -0,0 +1,1698 @@ +//! Community-scoped NIP-PL lease and durable wake-outbox persistence. +//! +//! Every operation requires a server-resolved [`CommunityId`]. Client-provided +//! origins never select rows in this module. + +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; +use sqlx::{PgPool, Row as _}; +use uuid::Uuid; + +use crate::error::Result; + +/// Maximum claims for a malformed matcher job before it is discarded. +pub const MAX_MATCH_ATTEMPTS: i32 = 8; + +/// Common signed-event ordering fields for a lease replacement. +#[derive(Debug, Clone, Copy)] +pub struct LeaseVersion<'a> { + /// Signed kind:30350 event id (32 bytes). + pub source_event_id: &'a [u8], + /// Signed event `created_at`, in Unix seconds. + pub source_created_at: i64, + /// Strictly increasing installation generation. + pub generation: i64, + /// Public NIP-40 expiration, in Unix seconds. + pub expires_at: i64, +} + +/// Effective fields for an active APNs lease. +#[derive(Debug, Clone, Copy)] +pub struct ActiveLease<'a> { + /// Application profile selected from the executor descriptor. + pub app_profile: &'a str, + /// SHA-256 of the platform endpoint. + pub endpoint_hash: &'a [u8], + /// Opaque endpoint grant issued by the stateless gateway. + pub endpoint_grant: &'a str, + /// Highest delivery class this lease permits. + pub max_class: &'a str, + /// Validated subscription array stored for matching. + pub subscriptions: &'a Value, +} + +/// Result of applying a lease replacement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplaceLeaseOutcome { + /// The replacement became the effective lease state. + Accepted, + /// The signed event did not win NIP-01 addressable-event ordering. + StaleEvent, + /// The generation did not exceed the persisted watermark. + StaleGeneration, +} + +/// Result of an idempotent outbox enqueue. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnqueueWakeOutcome { + /// A new durable job was inserted. + Enqueued(Uuid), + /// The endpoint/event dedup key already had a durable job. + Duplicate(Uuid), + /// No current active, unexpired lease matched the supplied generation. + InactiveLease, +} + +/// Durable wake fields not copied from the effective lease. +#[derive(Debug, Clone, Copy)] +pub struct NewWake<'a> { + /// Generation observed by the matcher. + pub lease_generation: i64, + /// Accepted event id that caused the wake (32 bytes). + pub event_id: &'a [u8], + /// Effective wake class. + pub class: &'a str, + /// Delivery deadline, in Unix seconds. + pub expires_at: i64, +} + +/// One exclusively claimed wake, already revalidated against its current lease. +#[derive(Debug, Clone, PartialEq)] +pub struct ClaimedWake { + /// Server-resolved tenant that owns this wake. + pub community: CommunityId, + /// Durable job id; this is also the stable gateway/APNs request id. + pub id: Uuid, + /// Claim fencing token required by every completion operation. + pub claim_id: Uuid, + /// Accepted event that caused the wake. + pub event_id: Vec, + /// Event channel used for send-time authorization revalidation. + pub channel_id: Option, + /// Lease author whose read authorization must be rechecked by the relay. + pub author: Vec, + /// Installation address within the community. + pub installation_id: String, + /// Generation captured when the job was enqueued. + pub lease_generation: i64, + /// Opaque endpoint capability for the stateless gateway. + pub endpoint_grant: String, + /// Wake class sent to the gateway. + pub class: String, + /// Delivery deadline, in Unix seconds. + pub expires_at: i64, + /// Attempt number, starting at one for the first claim. + pub attempt: i32, +} + +/// Outcome when a worker performs the final, load-bearing send-time check. +#[derive(Debug, Clone, PartialEq)] +pub enum RevalidateWakeOutcome { + /// The claim and current lease still authorize delivery. + Deliver(Box), + /// The claim was lost or the lease rotated, revoked, expired, or disabled. + Suppressed, +} + +/// One durably accepted event claimed for push matching. +#[derive(Debug, Clone)] +pub struct ClaimedMatch { + /// Tenant that owns both the event and matcher job. + pub community: CommunityId, + /// Non-deleted source event loaded after the claim commits. + pub event: buzz_core::StoredEvent, + /// Fencing token required to complete or retry this claim. + pub claim_id: Uuid, + /// Attempt number, starting at one for the first claim. + pub attempt: i32, +} + +/// Current active lease candidate for matcher evaluation. +#[derive(Debug, Clone)] +pub struct MatchLease { + /// Lease owner's raw public key. + pub author: Vec, + /// Installation address within the tenant. + pub installation_id: String, + /// Monotonic generation captured into any resulting wake. + pub generation: i64, + /// Validated restricted subscription array. + pub subscriptions: Value, + /// Lease expiry as a Unix timestamp. + pub expires_at: i64, +} + +/// Result of atomically accepting a signed push lease and its effective state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcceptLeaseOutcome { + /// The source event and effective lease committed together. + Accepted, + /// The incoming event lost NIP-01 addressable ordering. + StaleEvent, + /// The incoming generation did not exceed the durable watermark. + StaleGeneration, + /// Another active address already owns this endpoint tuple. + EndpointAlreadyLeased, + /// The author already has the configured maximum active leases. + LeaseQuotaExceeded, + /// The source event id is already bound to another lease address. + SourceEventCollision, + /// A validated lease still violated a database integrity constraint. + ConstraintViolation, +} + +/// Atomically persist one validated kind:30350 event and its effective lease. +/// +/// All policy inputs must already be validated. The transaction serializes both +/// the lease address and author-wide quota/endpoint namespace before changing +/// either the public source event or effective state. +#[allow(clippy::too_many_arguments)] +pub async fn accept_lease_event( + pool: &PgPool, + community: CommunityId, + event: &nostr::Event, + installation_id: &str, + version: LeaseVersion<'_>, + active: Option>, + max_active_leases: i64, +) -> Result { + let author = event.pubkey.as_bytes(); + let mut tx = pool.begin().await?; + let mut address_lock = Vec::with_capacity(16 + author.len() + installation_id.len()); + address_lock.extend_from_slice(community.as_uuid().as_bytes()); + address_lock.extend_from_slice(author); + address_lock.extend_from_slice(installation_id.as_bytes()); + let address_lock = i64::from_le_bytes(Sha256::digest(&address_lock)[..8].try_into().unwrap()); + let mut author_lock = Vec::with_capacity(16 + author.len()); + author_lock.extend_from_slice(community.as_uuid().as_bytes()); + author_lock.extend_from_slice(author); + let author_lock = i64::from_le_bytes(Sha256::digest(&author_lock)[..8].try_into().unwrap()); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(address_lock) + .execute(&mut *tx) + .await?; + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(author_lock) + .execute(&mut *tx) + .await?; + + if let Some(row) = sqlx::query( + "SELECT author, installation_id FROM push_leases WHERE community_id=$1 AND source_event_id=$2", + ) + .bind(community.as_uuid()) + .bind(version.source_event_id) + .fetch_optional(&mut *tx) + .await? + { + let existing_author: Vec = row.try_get("author")?; + let existing_installation: String = row.try_get("installation_id")?; + if existing_author.as_slice() != author || existing_installation != installation_id { + return Ok(AcceptLeaseOutcome::SourceEventCollision); + } + return Ok(AcceptLeaseOutcome::StaleEvent); + } + + if let Some(row) = sqlx::query( + "SELECT source_event_id, source_created_at, generation FROM push_leases WHERE community_id=$1 AND author=$2 AND installation_id=$3 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(author) + .bind(installation_id) + .fetch_optional(&mut *tx) + .await? + { + let current_created_at: i64 = row.try_get("source_created_at")?; + let current_event_id: Vec = row.try_get("source_event_id")?; + let current_generation: i64 = row.try_get("generation")?; + let wins_event = version.source_created_at > current_created_at + || (version.source_created_at == current_created_at + && version.source_event_id < current_event_id.as_slice()); + if !wins_event { + return Ok(AcceptLeaseOutcome::StaleEvent); + } + if version.generation <= current_generation { + return Ok(AcceptLeaseOutcome::StaleGeneration); + } + } + + // Expired leases are ineffective and must not consume quota or endpoint + // uniqueness forever. The author lock makes this cleanup atomic with the + // subsequent author-wide checks and replacement. + sqlx::query( + "UPDATE push_leases SET active=false, endpoint_enabled=false, updated_at=now() \ + WHERE community_id=$1 AND author=$2 AND active \ + AND expires_at <= EXTRACT(EPOCH FROM now())::bigint", + ) + .bind(community.as_uuid()) + .bind(author) + .execute(&mut *tx) + .await?; + + if let Some(active) = active { + let active_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_leases WHERE community_id=$1 AND author=$2 AND active AND installation_id<>$3", + ) + .bind(community.as_uuid()) + .bind(author) + .bind(installation_id) + .fetch_one(&mut *tx) + .await?; + if active_count >= max_active_leases { + return Ok(AcceptLeaseOutcome::LeaseQuotaExceeded); + } + let duplicate: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM push_leases WHERE community_id=$1 AND author=$2 AND installation_id<>$3 AND active AND app_profile=$4 AND endpoint_hash=$5)", + ) + .bind(community.as_uuid()) + .bind(author) + .bind(installation_id) + .bind(active.app_profile) + .bind(active.endpoint_hash) + .fetch_one(&mut *tx) + .await?; + if duplicate { + return Ok(AcceptLeaseOutcome::EndpointAlreadyLeased); + } + } + + sqlx::query( + "UPDATE events SET deleted_at=now() WHERE community_id=$1 AND kind=30350 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(author) + .bind(installation_id) + .execute(&mut *tx) + .await?; + let created_at = DateTime::from_timestamp(version.source_created_at, 0) + .ok_or(crate::DbError::InvalidTimestamp(version.source_created_at))?; + if let Err(error) = sqlx::query( + "INSERT INTO events (community_id,id,pubkey,created_at,kind,tags,content,sig,received_at,channel_id,d_tag) VALUES ($1,$2,$3,$4,30350,$5,$6,$7,now(),NULL,$8)", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(author) + .bind(created_at) + .bind(serde_json::to_value(&event.tags)?) + .bind(&event.content) + .bind(event.sig.serialize().as_slice()) + .bind(installation_id) + .execute(&mut *tx) + .await + { + if let Some(outcome) = constraint_acceptance_outcome(&error) { + return Ok(outcome); + } + return Err(error.into()); + } + + let (is_active, app_profile, endpoint_hash, endpoint_grant, max_class, subscriptions) = active + .map_or((false, None, None, None, None, None), |active| { + ( + true, + Some(active.app_profile), + Some(active.endpoint_hash), + Some(active.endpoint_grant), + Some(active.max_class), + Some(active.subscriptions), + ) + }); + if let Err(error) = sqlx::query( + r#"INSERT INTO push_leases (community_id,author,installation_id,source_event_id, + source_created_at,generation,active,app_profile,endpoint_hash,endpoint_grant,max_class, + subscriptions,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + ON CONFLICT (community_id,author,installation_id) DO UPDATE SET + source_event_id=EXCLUDED.source_event_id, source_created_at=EXCLUDED.source_created_at, + generation=EXCLUDED.generation, active=EXCLUDED.active, endpoint_enabled=true, + app_profile=EXCLUDED.app_profile, endpoint_hash=EXCLUDED.endpoint_hash, + endpoint_grant=EXCLUDED.endpoint_grant, max_class=EXCLUDED.max_class, + subscriptions=EXCLUDED.subscriptions, expires_at=EXCLUDED.expires_at, updated_at=now()"#, + ) + .bind(community.as_uuid()).bind(author).bind(installation_id) + .bind(version.source_event_id).bind(version.source_created_at).bind(version.generation) + .bind(is_active).bind(app_profile).bind(endpoint_hash).bind(endpoint_grant) + .bind(max_class).bind(subscriptions).bind(version.expires_at) + .execute(&mut *tx).await + { + if let Some(outcome) = constraint_acceptance_outcome(&error) { + return Ok(outcome); + } + return Err(error.into()); + } + tx.commit().await?; + Ok(AcceptLeaseOutcome::Accepted) +} + +fn constraint_acceptance_outcome(error: &sqlx::Error) -> Option { + let sqlx::Error::Database(error) = error else { + return None; + }; + match error.code().as_deref() { + Some("23505") if error.constraint() == Some("push_leases_endpoint_unique") => { + Some(AcceptLeaseOutcome::EndpointAlreadyLeased) + } + Some("23505") + if error.constraint() == Some("push_leases_community_id_source_event_id_key") => + { + Some(AcceptLeaseOutcome::SourceEventCollision) + } + // Every integrity violation is a protocol-invalid lease, even if a + // future migration renames/adds a constraint that validation missed. + Some(code) if code.starts_with("23") => Some(AcceptLeaseOutcome::ConstraintViolation), + _ => None, + } +} + +/// Create or rotate an active lease if both ordering gates win atomically. +#[allow(clippy::too_many_arguments)] +pub async fn replace_active_lease( + pool: &PgPool, + community: CommunityId, + author: &[u8], + installation_id: &str, + version: LeaseVersion<'_>, + active: ActiveLease<'_>, +) -> Result { + replace_lease( + pool, + community, + author, + installation_id, + version, + Some(active), + ) + .await +} + +/// Revoke one installation with a higher-generation inactive replacement. +pub async fn revoke_lease( + pool: &PgPool, + community: CommunityId, + author: &[u8], + installation_id: &str, + version: LeaseVersion<'_>, +) -> Result { + replace_lease(pool, community, author, installation_id, version, None).await +} + +async fn replace_lease( + pool: &PgPool, + community: CommunityId, + author: &[u8], + installation_id: &str, + version: LeaseVersion<'_>, + active: Option>, +) -> Result { + let (is_active, app_profile, endpoint_hash, endpoint_grant, max_class, subscriptions) = + match active { + Some(active) => ( + true, + Some(active.app_profile), + Some(active.endpoint_hash), + Some(active.endpoint_grant), + Some(active.max_class), + Some(active.subscriptions), + ), + None => (false, None, None, None, None, None), + }; + + // The conflict predicate is the acceptance state machine. Keeping both + // orderings in the upsert closes the missing-row race: concurrent initial + // publications cannot bypass a preceding SELECT/row lock. + let accepted = sqlx::query( + r#" + INSERT INTO push_leases ( + community_id, author, installation_id, source_event_id, + source_created_at, generation, active, app_profile, endpoint_hash, + endpoint_grant, max_class, subscriptions, expires_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ON CONFLICT (community_id, author, installation_id) DO UPDATE SET + source_event_id = EXCLUDED.source_event_id, + source_created_at = EXCLUDED.source_created_at, + generation = EXCLUDED.generation, + active = EXCLUDED.active, + endpoint_enabled = true, + app_profile = EXCLUDED.app_profile, + endpoint_hash = EXCLUDED.endpoint_hash, + endpoint_grant = EXCLUDED.endpoint_grant, + max_class = EXCLUDED.max_class, + subscriptions = EXCLUDED.subscriptions, + expires_at = EXCLUDED.expires_at, + updated_at = now() + WHERE ( + EXCLUDED.source_created_at > push_leases.source_created_at + OR ( + EXCLUDED.source_created_at = push_leases.source_created_at + AND EXCLUDED.source_event_id < push_leases.source_event_id + ) + ) + AND EXCLUDED.generation > push_leases.generation + RETURNING generation + "#, + ) + .bind(community.as_uuid()) + .bind(author) + .bind(installation_id) + .bind(version.source_event_id) + .bind(version.source_created_at) + .bind(version.generation) + .bind(is_active) + .bind(app_profile) + .bind(endpoint_hash) + .bind(endpoint_grant) + .bind(max_class) + .bind(subscriptions) + .bind(version.expires_at) + .fetch_optional(pool) + .await?; + + if accepted.is_some() { + return Ok(ReplaceLeaseOutcome::Accepted); + } + + let current = sqlx::query( + "SELECT source_event_id, source_created_at, generation \ + FROM push_leases \ + WHERE community_id = $1 AND author = $2 AND installation_id = $3", + ) + .bind(community.as_uuid()) + .bind(author) + .bind(installation_id) + .fetch_one(pool) + .await?; + let current_created_at: i64 = current.try_get("source_created_at")?; + let current_event_id: Vec = current.try_get("source_event_id")?; + let wins_event_order = version.source_created_at > current_created_at + || (version.source_created_at == current_created_at + && version.source_event_id < current_event_id.as_slice()); + if !wins_event_order { + Ok(ReplaceLeaseOutcome::StaleEvent) + } else { + Ok(ReplaceLeaseOutcome::StaleGeneration) + } +} + +/// Atomically enqueue at most one job per community, endpoint, and event. +/// +/// Endpoint identity and the endpoint grant are copied from the current lease; +/// callers cannot redirect a wake by supplying either value. A generation that +/// lost a replacement race is ineligible in the same statement that inserts. +pub async fn enqueue_wake( + pool: &PgPool, + community: CommunityId, + author: &[u8], + installation_id: &str, + wake: NewWake<'_>, +) -> Result { + let mut tx = pool.begin().await?; + // Serialize against lease replacement. If enqueue wins the lock, a later + // replacement can leave this durable job queued, but worker revalidation + // will suppress it; if replacement wins, the generation predicate fails. + let endpoint_hash = sqlx::query( + r#" + SELECT endpoint_hash + FROM push_leases + WHERE community_id = $1 + AND author = $2 + AND installation_id = $3 + AND generation = $4 + AND active + AND endpoint_enabled + AND expires_at > EXTRACT(EPOCH FROM now())::bigint + FOR UPDATE + "#, + ) + .bind(community.as_uuid()) + .bind(author) + .bind(installation_id) + .bind(wake.lease_generation) + .fetch_optional(&mut *tx) + .await?; + let Some(endpoint_hash) = endpoint_hash else { + return Ok(EnqueueWakeOutcome::InactiveLease); + }; + let endpoint_hash: Vec = endpoint_hash.try_get("endpoint_hash")?; + + let inserted = sqlx::query( + r#" + INSERT INTO push_wake_outbox ( + community_id, author, installation_id, lease_generation, + endpoint_hash, event_id, class, expires_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (community_id, endpoint_hash, event_id) DO NOTHING + RETURNING id + "#, + ) + .bind(community.as_uuid()) + .bind(author) + .bind(installation_id) + .bind(wake.lease_generation) + .bind(&endpoint_hash) + .bind(wake.event_id) + .bind(wake.class) + .bind(wake.expires_at) + .fetch_optional(&mut *tx) + .await?; + + let outcome = if let Some(row) = inserted { + EnqueueWakeOutcome::Enqueued(row.try_get("id")?) + } else { + // This is a separate statement so READ COMMITTED observes a competing + // transaction whose unique-key insert completed while ours waited. + let row = sqlx::query( + "SELECT id FROM push_wake_outbox \ + WHERE community_id = $1 AND endpoint_hash = $2 AND event_id = $3", + ) + .bind(community.as_uuid()) + .bind(&endpoint_hash) + .bind(wake.event_id) + .fetch_one(&mut *tx) + .await?; + EnqueueWakeOutcome::Duplicate(row.try_get("id")?) + }; + tx.commit().await?; + Ok(outcome) +} + +/// Exclusively claim the next due matcher job and load its non-deleted event. +pub async fn claim_due_match( + pool: &PgPool, + lease_until: DateTime, +) -> Result> { + claim_due_match_with_loader(pool, lease_until, |pool, community, event_id| async move { + Ok( + crate::event::get_events_by_ids(&pool, community, &[&event_id]) + .await? + .into_iter() + .next(), + ) + }) + .await +} + +async fn claim_due_match_with_loader( + pool: &PgPool, + lease_until: DateTime, + load: F, +) -> Result> +where + F: FnOnce(PgPool, CommunityId, Vec) -> Fut, + Fut: std::future::Future>>, +{ + let claim_id = Uuid::new_v4(); + let mut tx = pool.begin().await?; + // Reap poison jobs before claiming so a worker crash on the final attempt + // cannot leave an unclaimable row pinning outbox retention forever. + sqlx::query( + "DELETE FROM push_match_queue WHERE attempts >= $1 \ + AND (state='pending' OR (state='matching' AND lease_until < now()))", + ) + .bind(MAX_MATCH_ATTEMPTS) + .execute(&mut *tx) + .await?; + let row = sqlx::query( + r#" + WITH candidate AS ( + SELECT community_id, event_id + FROM push_match_queue + WHERE attempts < $3 + AND next_attempt_at <= now() + AND (state = 'pending' OR (state = 'matching' AND lease_until < now())) + ORDER BY next_attempt_at, created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE push_match_queue q + SET state='matching', claim_id=$1, lease_until=$2, attempts=attempts+1 + FROM candidate c + WHERE q.community_id=c.community_id AND q.event_id=c.event_id + RETURNING q.community_id, q.event_id, q.attempts + "#, + ) + .bind(claim_id) + .bind(lease_until) + .bind(MAX_MATCH_ATTEMPTS) + .fetch_optional(&mut *tx) + .await?; + let Some(row) = row else { + tx.commit().await?; + return Ok(None); + }; + let community = CommunityId::from_uuid(row.try_get("community_id")?); + let event_id: Vec = row.try_get("event_id")?; + let attempt: i32 = row.try_get("attempts")?; + tx.commit().await?; + let event = load(pool.clone(), community, event_id.clone()).await?; + let Some(event) = event else { + // Source absence and soft deletion are deliberate privacy-preserving + // terminal outcomes. Query errors above propagate instead, leaving the + // fenced job recoverable after its claim lease expires. + sqlx::query( + "DELETE FROM push_match_queue \ + WHERE community_id=$1 AND event_id=$2 AND claim_id=$3 AND state='matching'", + ) + .bind(community.as_uuid()) + .bind(&event_id) + .bind(claim_id) + .execute(pool) + .await?; + return Ok(None); + }; + Ok(Some(ClaimedMatch { + community, + event, + claim_id, + attempt, + })) +} + +/// Load active endpoint-enabled leases for one tenant. +pub async fn active_match_leases(pool: &PgPool, community: CommunityId) -> Result> { + let rows = sqlx::query( + "SELECT author, installation_id, generation, subscriptions, expires_at \ + FROM push_leases WHERE community_id=$1 AND active AND endpoint_enabled \ + AND expires_at > EXTRACT(EPOCH FROM now())::bigint", + ) + .bind(community.as_uuid()) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(|row| { + Ok(MatchLease { + author: row.try_get("author")?, + installation_id: row.try_get("installation_id")?, + generation: row.try_get("generation")?, + subscriptions: row.try_get("subscriptions")?, + expires_at: row.try_get("expires_at")?, + }) + }) + .collect() +} + +/// Delete a matcher job only while its claim fence is held. +pub async fn complete_match(pool: &PgPool, match_job: &ClaimedMatch) -> Result { + Ok(sqlx::query("DELETE FROM push_match_queue WHERE community_id=$1 AND event_id=$2 AND claim_id=$3 AND state='matching'") + .bind(match_job.community.as_uuid()).bind(match_job.event.event.id.as_bytes().as_slice()) + .bind(match_job.claim_id).execute(pool).await?.rows_affected() == 1) +} + +/// Release a fenced matcher claim for retry at the supplied time. +pub async fn retry_match( + pool: &PgPool, + match_job: &ClaimedMatch, + next: DateTime, +) -> Result { + Ok(sqlx::query("UPDATE push_match_queue SET state='pending', claim_id=NULL, lease_until=NULL, next_attempt_at=$4 WHERE community_id=$1 AND event_id=$2 AND claim_id=$3 AND state='matching'") + .bind(match_job.community.as_uuid()).bind(match_job.event.event.id.as_bytes().as_slice()) + .bind(match_job.claim_id).bind(next).execute(pool).await?.rows_affected() == 1) +} + +/// Claim due jobs for one community, recovering expired worker leases. +/// +/// Claiming performs an early lease check, but callers MUST invoke +/// [`revalidate_wake_for_send`] immediately before the transport call. +pub async fn claim_due_wakes( + pool: &PgPool, + community: CommunityId, + limit: i64, + lease_until: DateTime, +) -> Result> { + let claim_id = Uuid::new_v4(); + let rows = sqlx::query( + r#" + WITH candidates AS ( + SELECT o.id, e.channel_id + FROM push_wake_outbox o + JOIN push_leases l + ON l.community_id = o.community_id + AND l.author = o.author + AND l.installation_id = o.installation_id + AND l.generation = o.lease_generation + AND l.endpoint_hash = o.endpoint_hash + LEFT JOIN events e + ON e.community_id = o.community_id + AND e.id = o.event_id + AND e.deleted_at IS NULL + WHERE o.community_id = $1 + AND e.id IS NOT NULL + AND o.expires_at > EXTRACT(EPOCH FROM now())::bigint + AND o.next_attempt_at <= now() + AND (o.state = 'pending' OR (o.state = 'sending' AND o.lease_until < now())) + AND l.active + AND l.endpoint_enabled + AND l.expires_at > EXTRACT(EPOCH FROM now())::bigint + ORDER BY o.next_attempt_at, o.created_at, o.id + FOR UPDATE OF o SKIP LOCKED + LIMIT $2 + ) + UPDATE push_wake_outbox o + SET state = 'sending', claim_id = $3, lease_until = $4, attempts = attempts + 1 + FROM candidates c, push_leases l + WHERE o.community_id = $1 + AND o.id = c.id + AND l.community_id = o.community_id + AND l.author = o.author + AND l.installation_id = o.installation_id + AND l.generation = o.lease_generation + AND l.endpoint_hash = o.endpoint_hash + RETURNING o.community_id, o.id, o.claim_id, o.event_id, c.channel_id, + o.author, o.installation_id, o.lease_generation, + l.endpoint_grant, o.class, o.expires_at, o.attempts + "#, + ) + .bind(community.as_uuid()) + .bind(limit) + .bind(claim_id) + .bind(lease_until) + .fetch_all(pool) + .await?; + + rows.into_iter().map(row_to_claimed_wake).collect() +} + +/// Revalidate a fenced claim immediately before sending it. +/// +/// This exact community + generation + endpoint join is the load-bearing RF1 +/// gate. Claim-time eligibility and replacement-time cancellation are only +/// optimizations; neither can replace this send-time check. +pub async fn revalidate_wake_for_send( + pool: &PgPool, + community: CommunityId, + id: Uuid, + claim_id: Uuid, +) -> Result { + let row = sqlx::query( + r#" + SELECT o.community_id, o.id, o.claim_id, o.event_id, e.channel_id, + o.author, o.installation_id, o.lease_generation, + l.endpoint_grant, o.class, o.expires_at, o.attempts + FROM push_wake_outbox o + JOIN push_leases l + ON l.community_id = o.community_id + AND l.author = o.author + AND l.installation_id = o.installation_id + AND l.generation = o.lease_generation + AND l.endpoint_hash = o.endpoint_hash + JOIN events e + ON e.community_id = o.community_id + AND e.id = o.event_id + AND e.deleted_at IS NULL + WHERE o.community_id = $1 + AND o.id = $2 + AND o.claim_id = $3 + AND o.state = 'sending' + AND o.lease_until >= now() + AND o.expires_at > EXTRACT(EPOCH FROM now())::bigint + AND l.active + AND l.endpoint_enabled + AND l.expires_at > EXTRACT(EPOCH FROM now())::bigint + "#, + ) + .bind(community.as_uuid()) + .bind(id) + .bind(claim_id) + .fetch_optional(pool) + .await?; + + row.map(row_to_claimed_wake) + .transpose()? + .map_or(Ok(RevalidateWakeOutcome::Suppressed), |wake| { + Ok(RevalidateWakeOutcome::Deliver(Box::new(wake))) + }) +} + +/// Mark a fenced claim delivered. Stale workers cannot complete a newer claim. +pub async fn complete_wake( + pool: &PgPool, + community: CommunityId, + id: Uuid, + claim_id: Uuid, +) -> Result { + let result = sqlx::query( + "UPDATE push_wake_outbox \ + SET state = 'delivered', claim_id = NULL, lease_until = NULL \ + WHERE community_id = $1 AND id = $2 AND claim_id = $3 AND state = 'sending'", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(claim_id) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + +/// Return a fenced claim to the pending queue for a bounded retry. +pub async fn retry_wake( + pool: &PgPool, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + next_attempt_at: DateTime, +) -> Result { + let result = sqlx::query( + "UPDATE push_wake_outbox \ + SET state = 'pending', next_attempt_at = $4, claim_id = NULL, lease_until = NULL \ + WHERE community_id = $1 AND id = $2 AND claim_id = $3 AND state = 'sending'", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(claim_id) + .bind(next_attempt_at) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + +/// Permanently fail one fenced claim without affecting its lease or siblings. +pub async fn fail_wake( + pool: &PgPool, + community: CommunityId, + id: Uuid, + claim_id: Uuid, +) -> Result { + let result = sqlx::query( + "UPDATE push_wake_outbox \ + SET state = 'failed', claim_id = NULL, lease_until = NULL \ + WHERE community_id = $1 AND id = $2 AND claim_id = $3 AND state = 'sending'", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(claim_id) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + +/// Disable exactly the current endpoint generation after a permanent response. +/// +/// Strict generation monotonicity is the underlying safety invariant. The +/// current-generation predicate makes stale responses clean no-ops. +pub async fn disable_endpoint_generation( + pool: &PgPool, + community: CommunityId, + author: &[u8], + installation_id: &str, + generation: i64, +) -> Result { + let result = sqlx::query( + "UPDATE push_leases SET endpoint_enabled = false, updated_at = now() \ + WHERE community_id = $1 AND author = $2 AND installation_id = $3 \ + AND generation = $4 AND active AND endpoint_enabled", + ) + .bind(community.as_uuid()) + .bind(author) + .bind(installation_id) + .bind(generation) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + +/// Delete terminal/expired outbox rows older than a retention cutoff. +/// +/// NIP-RS hard purge only targets kind 30078, which is not push-eligible and +/// therefore cannot have a matcher row; any other absent source is handled by +/// the matcher's fenced load-miss deletion. +pub async fn prune_wake_outbox( + pool: &PgPool, + community: CommunityId, + before: DateTime, +) -> Result { + let result = sqlx::query( + "DELETE FROM push_wake_outbox o \ + WHERE o.community_id = $1 AND o.created_at < $2 \ + AND (o.state IN ('delivered', 'failed') \ + OR o.expires_at <= EXTRACT(EPOCH FROM now())::bigint) \ + AND NOT EXISTS ( \ + SELECT 1 FROM push_match_queue q \ + WHERE q.community_id = o.community_id AND q.event_id = o.event_id \ + )", + ) + .bind(community.as_uuid()) + .bind(before) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} + +fn row_to_claimed_wake(row: sqlx::postgres::PgRow) -> Result { + Ok(ClaimedWake { + community: CommunityId::from_uuid(row.try_get("community_id")?), + id: row.try_get("id")?, + claim_id: row.try_get("claim_id")?, + event_id: row.try_get("event_id")?, + channel_id: row.try_get("channel_id")?, + author: row.try_get("author")?, + installation_id: row.try_get("installation_id")?, + lease_generation: row.try_get("lease_generation")?, + endpoint_grant: row.try_get("endpoint_grant")?, + class: row.try_get("class")?, + expires_at: row.try_get("expires_at")?, + attempt: row.try_get("attempts")?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::migration; + use std::sync::Arc; + use tokio::sync::Barrier; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test DB"); + migration::run_migrations(&pool) + .await + .expect("run migrations"); + pool + } + + fn lease_event(keys: &nostr::Keys, installation: &str, created_at: u64) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(30_350), "ciphertext") + .tag(nostr::Tag::parse(["d", installation]).expect("d tag")) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(keys) + .expect("sign lease event") + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("push-test-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert community"); + CommunityId::from_uuid(id) + } + + fn version(event: u8, created_at: i64, generation: i64) -> LeaseVersion<'static> { + LeaseVersion { + source_event_id: Box::leak(Box::new([event; 32])), + source_created_at: created_at, + generation, + expires_at: i64::MAX / 2, + } + } + + async fn activate( + pool: &PgPool, + community: CommunityId, + author: &[u8], + installation: &str, + endpoint: &[u8], + generation: i64, + ) { + assert_eq!( + replace_active_lease( + pool, + community, + author, + installation, + version(generation as u8, generation * 10, generation), + ActiveLease { + app_profile: "ios-production", + endpoint_hash: endpoint, + endpoint_grant: "opaque-grant", + max_class: "default", + subscriptions: &serde_json::json!([]), + }, + ) + .await + .expect("activate lease"), + ReplaceLeaseOutcome::Accepted + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn acceptance_constraint_failure_rolls_back_source_event() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let keys = nostr::Keys::generate(); + let event = lease_event(&keys, "install", 100); + let endpoint = [42; 32]; + let subscriptions = serde_json::json!([]); + + let outcome = accept_lease_event( + &pool, + community, + &event, + "install", + LeaseVersion { + source_event_id: event.id.as_bytes(), + source_created_at: 100, + generation: 1, + expires_at: 200, + }, + Some(ActiveLease { + app_profile: "ios-production", + endpoint_hash: &endpoint, + endpoint_grant: "opaque-grant", + max_class: "not-a-class", + subscriptions: &subscriptions, + }), + 16, + ) + .await + .expect("constraint maps to an acceptance outcome"); + assert_eq!(outcome, AcceptLeaseOutcome::ConstraintViolation); + + let event_count: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count source events"); + let lease_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_leases WHERE community_id=$1 AND author=$2 AND installation_id=$3", + ) + .bind(community.as_uuid()) + .bind(event.pubkey.as_bytes()) + .bind("install") + .fetch_one(&pool) + .await + .expect("count leases"); + assert_eq!((event_count, lease_count), (0, 0)); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn source_event_collision_is_protocol_outcome_without_event_insert() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let keys = nostr::Keys::generate(); + let event = lease_event(&keys, "incoming", 100); + let author = event.pubkey.to_bytes(); + let endpoint = [43; 32]; + let subscriptions = serde_json::json!([]); + replace_active_lease( + &pool, + community, + &author, + "existing", + LeaseVersion { + source_event_id: event.id.as_bytes(), + source_created_at: 90, + generation: 1, + expires_at: 200, + }, + ActiveLease { + app_profile: "ios-production", + endpoint_hash: &endpoint, + endpoint_grant: "opaque-grant", + max_class: "default", + subscriptions: &subscriptions, + }, + ) + .await + .expect("seed colliding lease"); + + let outcome = accept_lease_event( + &pool, + community, + &event, + "incoming", + LeaseVersion { + source_event_id: event.id.as_bytes(), + source_created_at: 100, + generation: 2, + expires_at: 200, + }, + None, + 16, + ) + .await + .expect("collision is not an internal error"); + assert_eq!(outcome, AcceptLeaseOutcome::SourceEventCollision); + let event_count: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count source events"); + assert_eq!(event_count, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn replacement_and_revoke_are_community_scoped_and_dual_ordered() { + let pool = setup_pool().await; + let a = make_community(&pool).await; + let b = make_community(&pool).await; + let author = [7; 32]; + let endpoint = [8; 32]; + activate(&pool, a, &author, "install", &endpoint, 1).await; + activate(&pool, b, &author, "install", &endpoint, 1).await; + + assert_eq!( + revoke_lease(&pool, a, &author, "install", version(2, 20, 2)) + .await + .expect("revoke A"), + ReplaceLeaseOutcome::Accepted + ); + assert_eq!( + replace_active_lease( + &pool, + a, + &author, + "install", + version(3, 15, 99), + ActiveLease { + app_profile: "ios-production", + endpoint_hash: &endpoint, + endpoint_grant: "grant", + max_class: "default", + subscriptions: &serde_json::json!([]), + }, + ) + .await + .expect("old event loses"), + ReplaceLeaseOutcome::StaleEvent + ); + + let active: bool = sqlx::query_scalar( + "SELECT active FROM push_leases \ + WHERE community_id = $1 AND author = $2 AND installation_id = $3", + ) + .bind(b.as_uuid()) + .bind(author) + .bind("install") + .fetch_one(&pool) + .await + .expect("read B"); + assert!(active, "revoking A must not touch B"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_enqueue_is_atomic_and_community_scoped() { + let pool = setup_pool().await; + let a = make_community(&pool).await; + let b = make_community(&pool).await; + let author = [9; 32]; + let endpoint = [10; 32]; + let event = [11; 32]; + activate(&pool, a, &author, "install", &endpoint, 1).await; + activate(&pool, b, &author, "install", &endpoint, 1).await; + + let barrier = Arc::new(Barrier::new(8)); + let mut tasks = Vec::new(); + for _ in 0..8 { + let pool = pool.clone(); + let barrier = barrier.clone(); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + enqueue_wake( + &pool, + a, + &author, + "install", + NewWake { + lease_generation: 1, + event_id: &event, + class: "default", + expires_at: i64::MAX / 2, + }, + ) + .await + .expect("enqueue") + })); + } + let mut ids = Vec::new(); + for task in tasks { + ids.push(match task.await.expect("join") { + EnqueueWakeOutcome::Enqueued(id) | EnqueueWakeOutcome::Duplicate(id) => id, + EnqueueWakeOutcome::InactiveLease => panic!("lease unexpectedly inactive"), + }); + } + assert!(ids.iter().all(|id| *id == ids[0])); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_wake_outbox \ + WHERE community_id = $1 AND endpoint_hash = $2 AND event_id = $3", + ) + .bind(a.as_uuid()) + .bind(endpoint) + .bind(event) + .fetch_one(&pool) + .await + .expect("count A jobs"); + assert_eq!(count, 1); + + assert!(matches!( + enqueue_wake( + &pool, + b, + &author, + "install", + NewWake { + lease_generation: 1, + event_id: &event, + class: "default", + expires_at: i64::MAX / 2, + }, + ) + .await + .expect("enqueue B"), + EnqueueWakeOutcome::Enqueued(_) + )); + let total: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_wake_outbox \ + WHERE endpoint_hash = $1 AND event_id = $2", + ) + .bind(endpoint) + .bind(event) + .fetch_one(&pool) + .await + .expect("count all jobs"); + assert_eq!(total, 2, "same dedup key is independent per community"); + } + + async fn enqueue_one( + pool: &PgPool, + community: CommunityId, + author: &[u8], + event_id: &[u8; 32], + generation: i64, + ) -> Uuid { + sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig) \ + VALUES ($1, $2, $3, to_timestamp(1), 9, '[]', '', $4)", + ) + .bind(community.as_uuid()) + .bind(event_id) + .bind([42_u8; 32]) + .bind([43_u8; 64]) + .execute(pool) + .await + .expect("insert wake source event"); + match enqueue_wake( + pool, + community, + author, + "install", + NewWake { + lease_generation: generation, + event_id, + class: "default", + expires_at: i64::MAX / 2, + }, + ) + .await + .expect("enqueue wake") + { + EnqueueWakeOutcome::Enqueued(id) => id, + other => panic!("expected fresh enqueue, got {other:?}"), + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn send_revalidation_suppresses_rotated_claim_and_retry_preserves_id() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let author = [12; 32]; + activate(&pool, community, &author, "install", &[13; 32], 1).await; + let id = enqueue_one(&pool, community, &author, &[14; 32], 1).await; + let claim = claim_due_wakes( + &pool, + community, + 1, + Utc::now() + chrono::Duration::minutes(1), + ) + .await + .expect("claim") + .pop() + .expect("claimed job"); + assert_eq!(claim.id, id); + assert_eq!(claim.attempt, 1); + + activate(&pool, community, &author, "install", &[15; 32], 2).await; + assert_eq!( + revalidate_wake_for_send(&pool, community, id, claim.claim_id) + .await + .expect("revalidate after rotate"), + RevalidateWakeOutcome::Suppressed + ); + + let event = [16; 32]; + let retry_id = enqueue_one(&pool, community, &author, &event, 2).await; + let first = claim_due_wakes( + &pool, + community, + 1, + Utc::now() + chrono::Duration::minutes(1), + ) + .await + .expect("first claim") + .into_iter() + .find(|wake| wake.id == retry_id) + .expect("retry job claimed"); + let database_now: DateTime = sqlx::query_scalar("SELECT now()") + .fetch_one(&pool) + .await + .expect("read database clock"); + assert!(retry_wake( + &pool, + community, + retry_id, + first.claim_id, + database_now - chrono::Duration::seconds(1), + ) + .await + .expect("schedule retry")); + let second = claim_due_wakes( + &pool, + community, + 10, + Utc::now() + chrono::Duration::minutes(1), + ) + .await + .expect("second claim") + .into_iter() + .find(|wake| wake.id == retry_id) + .expect("retry reclaimed"); + assert_eq!(second.id, first.id, "durable request id must be stable"); + assert_eq!(second.attempt, 2); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn endpoint_invalidation_is_scoped_to_community_and_generation() { + let pool = setup_pool().await; + let a = make_community(&pool).await; + let b = make_community(&pool).await; + let author = [17; 32]; + let endpoint = [18; 32]; + activate(&pool, a, &author, "install", &endpoint, 1).await; + activate(&pool, b, &author, "install", &endpoint, 1).await; + + assert!(disable_endpoint_generation(&pool, a, &author, "install", 1) + .await + .expect("disable A generation 1")); + assert!( + !disable_endpoint_generation(&pool, a, &author, "install", 1) + .await + .expect("duplicate disable is a no-op") + ); + assert!(matches!( + enqueue_wake( + &pool, + a, + &author, + "install", + NewWake { + lease_generation: 1, + event_id: &[19; 32], + class: "default", + expires_at: i64::MAX / 2, + }, + ) + .await + .expect("enqueue disabled A"), + EnqueueWakeOutcome::InactiveLease + )); + assert!(matches!( + enqueue_wake( + &pool, + b, + &author, + "install", + NewWake { + lease_generation: 1, + event_id: &[19; 32], + class: "default", + expires_at: i64::MAX / 2, + }, + ) + .await + .expect("enqueue healthy B"), + EnqueueWakeOutcome::Enqueued(_) + )); + + activate(&pool, a, &author, "install", &[20; 32], 2).await; + assert!( + !disable_endpoint_generation(&pool, a, &author, "install", 1) + .await + .expect("stale response") + ); + assert!(matches!( + enqueue_wake( + &pool, + a, + &author, + "install", + NewWake { + lease_generation: 2, + event_id: &[21; 32], + class: "default", + expires_at: i64::MAX / 2, + }, + ) + .await + .expect("new generation stays enabled"), + EnqueueWakeOutcome::Enqueued(_) + )); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn matcher_trigger_is_allowlisted_and_deleted_events_are_discarded() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let keys = nostr::Keys::generate(); + let push_event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "push") + .sign_with_keys(&keys) + .expect("sign push event"); + let read_state = nostr::EventBuilder::new(nostr::Kind::Custom(30_078), "read") + .sign_with_keys(&keys) + .expect("sign read state"); + crate::event::insert_event(&pool, community, &push_event, None) + .await + .expect("insert push event"); + crate::event::insert_event(&pool, community, &read_state, None) + .await + .expect("insert non-push event"); + + let queued: Vec = sqlx::query_scalar( + "SELECT e.kind FROM push_match_queue q JOIN events e \ + ON e.community_id=q.community_id AND e.id=q.event_id \ + WHERE q.community_id=$1", + ) + .bind(community.as_uuid()) + .fetch_all(&pool) + .await + .expect("read matcher queue"); + assert_eq!(queued, vec![9]); + + sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(push_event.id.as_bytes().as_slice()) + .execute(&pool) + .await + .expect("soft delete before matching"); + assert!( + claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1)) + .await + .expect("claim deleted event") + .is_none() + ); + let remaining: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_match_queue WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count discarded job"); + assert_eq!(remaining, 0, "deleted content must never produce a wake"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn matcher_load_error_preserves_claimed_job_for_recovery() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "retry me") + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign event"); + crate::event::insert_event(&pool, community, &event, None) + .await + .expect("insert event"); + + let error = claim_due_match_with_loader( + &pool, + Utc::now() - chrono::Duration::seconds(1), + |_pool, _community, _event_id| async { + Err(crate::DbError::InvalidData("injected load failure".into())) + }, + ) + .await + .expect_err("load error must propagate"); + assert!(error.to_string().contains("injected load failure")); + let row: (String, i32) = sqlx::query_as( + "SELECT state, attempts FROM push_match_queue WHERE community_id=$1 AND event_id=$2", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("load failure must preserve matcher row"); + assert_eq!(row, ("matching".to_string(), 1)); + assert!( + claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1)) + .await + .expect("expired claim remains recoverable") + .is_some() + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn matcher_claim_is_exclusive_across_workers() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "one job") + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign event"); + crate::event::insert_event(&pool, community, &event, None) + .await + .expect("insert event"); + let barrier = Arc::new(Barrier::new(8)); + let mut tasks = Vec::new(); + for _ in 0..8 { + let pool = pool.clone(); + let barrier = Arc::clone(&barrier); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1)) + .await + .expect("claim matcher job") + })); + } + let mut claimed = 0; + for task in tasks { + claimed += usize::from(task.await.expect("join").is_some()); + } + assert_eq!(claimed, 1); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn delivered_wake_is_retained_while_rematch_is_queued() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let author = [22; 32]; + let event_id = [23; 32]; + activate(&pool, community, &author, "install", &[24; 32], 1).await; + let wake_id = enqueue_one(&pool, community, &author, &event_id, 1).await; + sqlx::query( + "UPDATE push_wake_outbox SET state='delivered', created_at=now()-interval '2 days' \ + WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(wake_id) + .execute(&pool) + .await + .expect("mark old wake delivered"); + + let cutoff = Utc::now() - chrono::Duration::days(1); + assert_eq!( + prune_wake_outbox(&pool, community, cutoff).await.unwrap(), + 0 + ); + sqlx::query("DELETE FROM push_match_queue WHERE community_id=$1 AND event_id=$2") + .bind(community.as_uuid()) + .bind(event_id) + .execute(&pool) + .await + .expect("complete rematch"); + assert_eq!( + prune_wake_outbox(&pool, community, cutoff).await.unwrap(), + 1 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn exhausted_match_job_is_reaped_and_cannot_pin_retention() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "poison") + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign event"); + crate::event::insert_event(&pool, community, &event, None) + .await + .expect("insert event"); + let author = [25; 32]; + activate(&pool, community, &author, "install", &[26; 32], 1).await; + let wake_id = match enqueue_wake( + &pool, + community, + &author, + "install", + NewWake { + lease_generation: 1, + event_id: event.id.as_bytes(), + class: "default", + expires_at: i64::MAX / 2, + }, + ) + .await + .expect("enqueue wake") + { + EnqueueWakeOutcome::Enqueued(id) => id, + other => panic!("expected fresh wake, got {other:?}"), + }; + sqlx::query( + "UPDATE push_wake_outbox SET state='delivered', created_at=now()-interval '2 days' \ + WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(wake_id) + .execute(&pool) + .await + .expect("mark old wake delivered"); + let cutoff = Utc::now() - chrono::Duration::days(1); + assert_eq!( + prune_wake_outbox(&pool, community, cutoff).await.unwrap(), + 0 + ); + sqlx::query( + "UPDATE push_match_queue SET attempts=$3, state='matching', lease_until=now()-interval '1 second' \ + WHERE community_id=$1 AND event_id=$2", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(MAX_MATCH_ATTEMPTS) + .execute(&pool) + .await + .expect("exhaust matcher job"); + assert!( + claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1)) + .await + .expect("reap exhausted matcher") + .is_none() + ); + let remaining: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_match_queue WHERE community_id=$1 AND event_id=$2", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(remaining, 0); + assert_eq!( + prune_wake_outbox(&pool, community, cutoff).await.unwrap(), + 1, + "reaped poison job must release delivered-wake retention" + ); + } +} diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 8a71d86095..d354b057bd 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -248,6 +248,14 @@ pub async fn update_relay_member_role( /// is never bootstrapped into community B. /// /// Runs in a single transaction. Safe to call at every startup — idempotent. +/// +/// **Deployment-root authority exception:** This function is called only by +/// startup initialization and legacy operator provisioning +/// (`community_provisioning.rs`). It is NOT an end-user path and does NOT +/// enforce the per-owner community limit (`MAX_COMMUNITIES_PER_OWNER`) or +/// acquire the per-recipient advisory lock. The per-owner limit is an +/// end-user invariant enforced by `create_community_with_owner` and +/// `transfer_ownership`; deployment-root operations may exceed it by design. pub async fn bootstrap_owner( pool: &PgPool, community: CommunityId, @@ -281,6 +289,158 @@ pub async fn bootstrap_owner( Ok(()) } +/// The result of a transfer-ownership attempt. +#[derive(Debug, PartialEq)] +pub enum TransferResult { + /// Transfer completed: the new owner was upserted and the previous + /// owner(s) demoted to `member`. + Transferred { + /// Pubkey of the previous sole owner, if exactly one existed. + previous_owner: Option, + }, + /// The new owner pubkey is already the sole owner — nothing to do. + AlreadyOwner, + /// No owner row exists for this community (community may not exist). + NoOwner, + /// The `expected_owner_pubkey` did not match the current owner. A + /// concurrent transfer or owner rotation has already changed ownership. + /// The caller must NOT retry blindly — re-read ownership and re-evaluate. + OwnerConflict, + /// The transferee already owns the maximum number of communities. + /// Enforced atomically inside the transfer transaction so concurrent + /// transfers to the same recipient cannot both pass the limit. + LimitReached, +} + +/// Maximum number of communities a single pubkey can own. Enforced at the +/// relay layer — the authoritative layer — so that concurrent transfers or +/// transfer-vs-create races cannot both pass a preflight count. +pub const MAX_COMMUNITIES_PER_OWNER: i64 = 3; + +/// Stable advisory-lock key for serializing ownership-granting operations +/// (transfer + create) per recipient pubkey. Uses FNV-1a over the hex pubkey +/// so the same recipient always maps to the same lock across processes. +pub fn owner_count_advisory_lock_key(pubkey_hex: &str) -> i64 { + let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis + for b in pubkey_hex.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); // FNV prime + } + h as i64 +} + +/// Atomically transfers ownership of `community` to `new_owner_pubkey`. +/// +/// Runs in a single transaction: +/// 1. Acquires a transaction-scoped advisory lock on the *transferee* pubkey +/// so that concurrent transfers to the same recipient serialize. The same +/// lock key is also used by `Db::create_community_with_owner` to prevent +/// transfer-vs-create races. +/// 2. Locks the current owner row `FOR UPDATE` and verifies +/// `expected_owner_pubkey` matches. This prevents a stale-owner race where +/// a delayed/retried request overwrites a completed transfer. +/// 3. Enforces the [`MAX_COMMUNITIES_PER_OWNER`] limit on the transferee by +/// counting owned communities inside the same transaction. +/// 4. Upserts `new_owner_pubkey` as `owner` (insert or promote). +/// 5. Demotes every other owner in this community to `member` — **not** +/// `admin`, per product decision: the former owner retains no management +/// capabilities. +/// +/// Scoped to one community — an ownership transfer in A never touches B. +pub async fn transfer_ownership( + pool: &PgPool, + community: CommunityId, + new_owner_pubkey: &str, + expected_owner_pubkey: &str, +) -> Result { + let pubkey = new_owner_pubkey.to_ascii_lowercase(); + let expected_owner = expected_owner_pubkey.to_ascii_lowercase(); + let mut tx = pool.begin().await?; + + // 1. Serialize on the transferee so concurrent transfers to the same + // recipient cannot both pass the ownership count check. + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(owner_count_advisory_lock_key(&pubkey)) + .execute(&mut *tx) + .await?; + + // 2. Lock the current owner row FOR UPDATE and verify the expected owner. + // FOR UPDATE prevents the stale-owner race: a concurrent transfer that + // already changed the owner will block on this lock until our txn + // completes (or vice versa), and the expected_owner check will fail. + let existing_owners: Vec = sqlx::query_scalar( + "SELECT pubkey FROM relay_members \ + WHERE community_id = $1 AND role = 'owner' \ + FOR UPDATE", + ) + .bind(community.as_uuid()) + .fetch_all(&mut *tx) + .await?; + + if existing_owners.is_empty() { + tx.rollback().await?; + return Ok(TransferResult::NoOwner); + } + + // Stale-owner guard: if the current owner doesn't match the expected + // owner, a concurrent transfer or rotation has already changed hands. + if !existing_owners.iter().any(|p| p == &expected_owner) { + tx.rollback().await?; + return Ok(TransferResult::OwnerConflict); + } + + // Already the sole owner — no transfer needed. + if existing_owners.len() == 1 && existing_owners[0] == pubkey { + tx.rollback().await?; + return Ok(TransferResult::AlreadyOwner); + } + + let previous_owner = if existing_owners.len() == 1 { + Some(existing_owners[0].clone()) + } else { + existing_owners.iter().find(|p| **p != pubkey).cloned() + }; + + // 3. Enforce the transferee's community ownership limit inside the same + // transaction that holds the advisory lock. This is the authoritative + // check — kgoose's preflight count is advisory only. + let owned_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM relay_members WHERE pubkey = $1 AND role = 'owner'", + ) + .bind(&pubkey) + .fetch_one(&mut *tx) + .await?; + + if owned_count >= MAX_COMMUNITIES_PER_OWNER { + tx.rollback().await?; + return Ok(TransferResult::LimitReached); + } + + // 4. Upsert the new owner. + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, 'owner', NULL) \ + ON CONFLICT (community_id, pubkey) DO UPDATE SET role = 'owner', updated_at = now()", + ) + .bind(community.as_uuid()) + .bind(&pubkey) + .execute(&mut *tx) + .await?; + + // 5. Demote all other owners to member (not admin). + sqlx::query( + "UPDATE relay_members SET role = 'member', updated_at = now() \ + WHERE community_id = $1 AND role = 'owner' AND pubkey <> $2", + ) + .bind(community.as_uuid()) + .bind(&pubkey) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(TransferResult::Transferred { previous_owner }) +} + /// Migrates existing `pubkey_allowlist` entries into `relay_members` for /// `community` (the deployment's default community). /// @@ -360,6 +520,30 @@ mod tests { CommunityId::from_uuid(id) } + fn test_pubkey() -> String { + format!("{:064x}", Uuid::new_v4().as_u128()) + } + + async fn assert_role(pool: &PgPool, community: CommunityId, pubkey: &str, role: &str) { + assert_eq!( + get_relay_member(pool, community, pubkey) + .await + .expect("get relay member") + .map(|member| member.role) + .as_deref(), + Some(role) + ); + } + + async fn owned_community(pool: &PgPool) -> (CommunityId, String) { + let community = make_test_community(pool).await; + let owner = test_pubkey(); + bootstrap_owner(pool, community, &owner) + .await + .expect("bootstrap owner"); + (community, owner) + } + /// NIP-43 admission confinement: a pubkey admitted to community A is *not* /// admitted to community B. This is the exact mutation #1285 targets — a /// `WHERE pubkey = $1` membership check (no community predicate) would let an @@ -373,7 +557,7 @@ mod tests { let community_a = make_test_community(&pool).await; let community_b = make_test_community(&pool).await; // 64-char lowercase hex, unique per run so reruns don't collide. - let pubkey = format!("{:064x}", Uuid::new_v4().as_u128()); + let pubkey = test_pubkey(); let inserted = add_relay_member(&pool, community_a, &pubkey, "member", None) .await @@ -437,7 +621,7 @@ mod tests { let pool = setup_pool().await; let community_a = make_test_community(&pool).await; let community_b = make_test_community(&pool).await; - let owner = format!("{:064x}", Uuid::new_v4().as_u128()); + let owner = test_pubkey(); bootstrap_owner(&pool, community_a, &owner) .await @@ -456,4 +640,220 @@ mod tests { "owner bootstrapped in A must NOT be a member of B" ); } + + /// Transfer ownership: upserts new owner, demotes previous owner to + /// `member` (not `admin`), and returns the previous owner's pubkey. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_demotes_old_owner_to_member() { + let pool = setup_pool().await; + let (community, old_owner) = owned_community(&pool).await; + let new_owner = test_pubkey(); + + let result = transfer_ownership(&pool, community, &new_owner, &old_owner) + .await + .expect("transfer ownership"); + + assert_eq!( + result, + TransferResult::Transferred { + previous_owner: Some(old_owner.clone()), + } + ); + + assert_role(&pool, community, &new_owner, "owner").await; + assert_role(&pool, community, &old_owner, "member").await; + } + + /// Transferring to the current sole owner is a no-op (`AlreadyOwner`). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_already_owner_is_noop() { + let pool = setup_pool().await; + let (community, owner) = owned_community(&pool).await; + + let result = transfer_ownership(&pool, community, &owner, &owner) + .await + .expect("transfer ownership to self"); + + assert_eq!(result, TransferResult::AlreadyOwner); + + assert_role(&pool, community, &owner, "owner").await; + } + + /// Transferring a community with no owner row returns `NoOwner`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_no_owner_returns_no_owner() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let new_owner = test_pubkey(); + let expected = test_pubkey(); + + // No bootstrap — community exists but has no owner row. + + let result = transfer_ownership(&pool, community, &new_owner, &expected) + .await + .expect("transfer ownership on empty community"); + + assert_eq!(result, TransferResult::NoOwner); + } + + /// Transfer ownership is community-scoped: transferring in A does not + /// affect ownership in B. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_is_community_scoped() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let owner_a = test_pubkey(); + let owner_b = test_pubkey(); + let new_owner = test_pubkey(); + + bootstrap_owner(&pool, community_a, &owner_a) + .await + .expect("bootstrap owner A"); + bootstrap_owner(&pool, community_b, &owner_b) + .await + .expect("bootstrap owner B"); + + transfer_ownership(&pool, community_a, &new_owner, &owner_a) + .await + .expect("transfer A"); + + assert_role(&pool, community_a, &new_owner, "owner").await; + assert_role(&pool, community_a, &owner_a, "member").await; + assert_role(&pool, community_b, &owner_b, "owner").await; + assert!( + !is_relay_member(&pool, community_b, &new_owner) + .await + .expect("is_relay_member B"), + "new owner of A must NOT be a member of B" + ); + } + + /// Transfer ownership to someone who is already a member promotes them to + /// owner and demotes the old owner to member. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_promotes_existing_member() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let old_owner = test_pubkey(); + let existing_member = test_pubkey(); + + bootstrap_owner(&pool, community, &old_owner) + .await + .expect("bootstrap owner"); + add_relay_member(&pool, community, &existing_member, "member", None) + .await + .expect("add member"); + + let result = transfer_ownership(&pool, community, &existing_member, &old_owner) + .await + .expect("transfer to existing member"); + + assert!(matches!(result, TransferResult::Transferred { .. })); + + assert_eq!( + get_relay_member(&pool, community, &existing_member) + .await + .expect("get new owner") + .expect("exists") + .role, + "owner" + ); + assert_eq!( + get_relay_member(&pool, community, &old_owner) + .await + .expect("get old owner") + .expect("exists") + .role, + "member" + ); + } + + /// Transfer returns `OwnerConflict` when `expected_owner_pubkey` doesn't + /// match the current owner — simulates a stale/delayed request after a + /// concurrent transfer has already changed ownership. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_returns_owner_conflict_when_expected_mismatches() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let old_owner = test_pubkey(); + let new_owner = test_pubkey(); + let wrong_expected = test_pubkey(); + + bootstrap_owner(&pool, community, &old_owner) + .await + .expect("bootstrap initial owner"); + + // expected_owner_pubkey doesn't match the actual owner — should conflict. + let result = transfer_ownership(&pool, community, &new_owner, &wrong_expected) + .await + .expect("transfer ownership with wrong expected"); + + assert_eq!(result, TransferResult::OwnerConflict); + + // Old owner is still owner — nothing changed. + assert_eq!( + get_relay_member(&pool, community, &old_owner) + .await + .expect("get old owner") + .expect("exists") + .role, + "owner" + ); + // New owner was not added. + assert!( + get_relay_member(&pool, community, &new_owner) + .await + .expect("get new owner") + .is_none(), + "new owner must not be added on conflict" + ); + } + + /// Transfer returns `LimitReached` when the transferee already owns the + /// maximum number of communities. The limit is enforced inside the + /// transfer transaction at the relay layer. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_returns_limit_reached_for_maxed_transferee() { + let pool = setup_pool().await; + let owner = test_pubkey(); + let transferee = test_pubkey(); + + // Give the transferee 3 communities (the max). + for _ in 0..3 { + let c = make_test_community(&pool).await; + bootstrap_owner(&pool, c, &transferee) + .await + .expect("bootstrap transferee community"); + } + + // Create a community owned by `owner` and try to transfer to `transferee`. + let community = make_test_community(&pool).await; + bootstrap_owner(&pool, community, &owner) + .await + .expect("bootstrap owner"); + + let result = transfer_ownership(&pool, community, &transferee, &owner) + .await + .expect("transfer to maxed transferee"); + + assert_eq!(result, TransferResult::LimitReached); + + // Owner is still owner — transfer did not happen. + assert_eq!( + get_relay_member(&pool, community, &owner) + .await + .expect("get owner") + .expect("exists") + .role, + "owner" + ); + } } diff --git a/crates/buzz-db/src/usage.rs b/crates/buzz-db/src/usage.rs new file mode 100644 index 0000000000..f009dc6e05 --- /dev/null +++ b/crates/buzz-db/src/usage.rs @@ -0,0 +1,722 @@ +//! Per-community usage rollup queries for Prometheus gauges. +//! +//! Stock queries (`user_counts`, `channel_counts`, `relay_member_counts`, +//! `workflow_counts`, `git_repo_counts`) use `GROUP BY community_id` against +//! indexed columns — no per-community loops, no full-table scans. +//! +//! Event-derived queries (`message_counts`, `active_user_counts`, +//! `active_channel_counts`) are exact aggregates over the `events` table. +//! At scale these can become recurring partition scans; if that becomes a +//! problem, move them to a maintained rollup table and drop the interval. +//! +//! Returned structs are plain data; the caller (relay poller) maps them +//! to Prometheus labels and calls `metrics::gauge!(...).set(...)`. + +use crate::error::Result; +use sqlx::PgPool; +use uuid::Uuid; + +/// Total number of communities registered on this relay. +pub async fn community_count(pool: &PgPool) -> Result { + let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM communities") + .fetch_one(pool) + .await?; + Ok(row) +} + +/// Per-community user counts split by human/agent. +#[derive(Debug)] +pub struct CommunityUserCounts { + /// The UUID of the community. + pub community_id: Uuid, + /// Number of active human users (no `agent_owner_pubkey`). + pub human: i64, + /// Number of active agent users (`agent_owner_pubkey IS NOT NULL`). + pub agent: i64, +} + +/// Return active (non-deactivated) user counts per community, split by type. +/// +/// Agent discriminator: `agent_owner_pubkey IS NOT NULL`. +pub async fn user_counts(pool: &PgPool) -> Result> { + // Single GROUP BY query; two conditional SUMs avoid two round-trips. + let rows = sqlx::query_as::<_, (Uuid, i64, i64)>( + r#" + SELECT + community_id, + COUNT(*) FILTER (WHERE agent_owner_pubkey IS NULL) AS human, + COUNT(*) FILTER (WHERE agent_owner_pubkey IS NOT NULL) AS agent + FROM users + WHERE deactivated_at IS NULL + GROUP BY community_id + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(community_id, human, agent)| CommunityUserCounts { + community_id, + human, + agent, + }) + .collect()) +} + +/// Per-community channel counts by type. +#[derive(Debug)] +pub struct CommunityChannelCount { + /// The UUID of the community. + pub community_id: Uuid, + /// Channel type string (e.g. `"stream"`, `"dm"`, `"forum"`, `"workflow"`). + pub channel_type: String, + /// Number of non-deleted channels of this type. + pub count: i64, +} + +/// Return non-deleted channel counts per community per type. +pub async fn channel_counts(pool: &PgPool) -> Result> { + let rows = sqlx::query_as::<_, (Uuid, String, i64)>( + r#" + SELECT community_id, channel_type::text, COUNT(*) AS count + FROM channels + WHERE deleted_at IS NULL + GROUP BY community_id, channel_type + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map( + |(community_id, channel_type, count)| CommunityChannelCount { + community_id, + channel_type, + count, + }, + ) + .collect()) +} + +/// Per-community message (kind=9) count. +#[derive(Debug)] +pub struct CommunityMessageCount { + /// The UUID of the community. + pub community_id: Uuid, + /// Number of stored non-deleted kind=9 events. + pub count: i64, +} + +/// Return non-deleted kind=9 event counts per community. +pub async fn message_counts(pool: &PgPool) -> Result> { + let rows = sqlx::query_as::<_, (Uuid, i64)>( + r#" + SELECT community_id, COUNT(*) AS count + FROM events + WHERE kind = 9 AND deleted_at IS NULL + GROUP BY community_id + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(community_id, count)| CommunityMessageCount { + community_id, + count, + }) + .collect()) +} + +/// Per-community relay-member counts by role. +#[derive(Debug)] +pub struct CommunityMemberCount { + /// The UUID of the community. + pub community_id: Uuid, + /// Role string (e.g. `"owner"`, `"admin"`, `"member"`). + pub role: String, + /// Number of members with this role. + pub count: i64, +} + +/// Return relay-member counts per community per role. +pub async fn relay_member_counts(pool: &PgPool) -> Result> { + let rows = sqlx::query_as::<_, (Uuid, String, i64)>( + r#" + SELECT community_id, role::text, COUNT(*) AS count + FROM relay_members + GROUP BY community_id, role + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(community_id, role, count)| CommunityMemberCount { + community_id, + role, + count, + }) + .collect()) +} + +/// Per-community workflow counts by status. +#[derive(Debug)] +pub struct CommunityWorkflowCount { + /// The UUID of the community. + pub community_id: Uuid, + /// Workflow status string (e.g. `"active"`, `"inactive"`). + pub status: String, + /// Number of workflows in this status. + pub count: i64, +} + +/// Return workflow counts per community per status. +pub async fn workflow_counts(pool: &PgPool) -> Result> { + let rows = sqlx::query_as::<_, (Uuid, String, i64)>( + r#" + SELECT community_id, status::text, COUNT(*) AS count + FROM workflows + GROUP BY community_id, status + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(community_id, status, count)| CommunityWorkflowCount { + community_id, + status, + count, + }) + .collect()) +} + +/// Per-community git-repo count. +#[derive(Debug)] +pub struct CommunityGitRepoCount { + /// The UUID of the community. + pub community_id: Uuid, + /// Number of git repos registered for this community. + pub count: i64, +} + +/// Return git repo counts per community. +pub async fn git_repo_counts(pool: &PgPool) -> Result> { + let rows = sqlx::query_as::<_, (Uuid, i64)>( + r#" + SELECT community_id, COUNT(*) AS count + FROM git_repo_names + GROUP BY community_id + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(community_id, count)| CommunityGitRepoCount { + community_id, + count, + }) + .collect()) +} + +/// Per-community active-user counts for a given window (e.g. 1d, 7d, 30d), +/// split by human/agent. +#[derive(Debug)] +pub struct CommunityActiveUsers { + /// The UUID of the community. + pub community_id: Uuid, + /// Distinct human pubkeys that published at least one event in the window. + /// A pubkey is human when its `users` row exists and `agent_owner_pubkey IS NULL`. + pub human: i64, + /// Distinct agent pubkeys that published at least one event in the window. + /// A pubkey is an agent when its `users` row exists and `agent_owner_pubkey IS NOT NULL`. + pub agent: i64, + /// Distinct pubkeys that published at least one event but have no `users` row. + /// Ingest does not guarantee a `users` row for every pubkey (profileless posters, + /// agents with missing rows). These are not classified and must not be folded into + /// `human` to avoid inflating the human count. + pub unknown: i64, +} + +/// Return distinct-publisher counts for events in `[now - interval, now]` +/// per community, split by human/agent/unknown. +/// +/// `interval_sql` must be a trusted literal (e.g. `"1 day"`, `"7 days"`) — +/// it is not user-controlled; callers are in the relay process. +pub async fn active_user_counts( + pool: &PgPool, + interval_sql: &'static str, +) -> Result> { + // LEFT JOIN users: pubkeys with no row have u.* = NULL. + // Three-way classification: + // human — row exists (u.pubkey IS NOT NULL) and agent_owner_pubkey IS NULL + // agent — row exists and agent_owner_pubkey IS NOT NULL + // unknown — no row (u.pubkey IS NULL); not classified, reported separately + let sql = format!( + r#" + SELECT + e.community_id, + COUNT(DISTINCT e.pubkey) + FILTER (WHERE u.pubkey IS NOT NULL AND u.agent_owner_pubkey IS NULL) AS human, + COUNT(DISTINCT e.pubkey) + FILTER (WHERE u.pubkey IS NOT NULL AND u.agent_owner_pubkey IS NOT NULL) AS agent, + COUNT(DISTINCT e.pubkey) + FILTER (WHERE u.pubkey IS NULL) AS unknown + FROM events e + LEFT JOIN users u + ON u.community_id = e.community_id AND u.pubkey = e.pubkey + WHERE e.created_at >= NOW() - INTERVAL '{interval_sql}' + AND e.deleted_at IS NULL + GROUP BY e.community_id + "# + ); + let rows = sqlx::query_as::<_, (Uuid, i64, i64, i64)>(sqlx::AssertSqlSafe(sql)) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map( + |(community_id, human, agent, unknown)| CommunityActiveUsers { + community_id, + human, + agent, + unknown, + }, + ) + .collect()) +} + +/// Per-community active-channel counts for a given window. +#[derive(Debug)] +pub struct CommunityActiveChannels { + /// The UUID of the community. + pub community_id: Uuid, + /// Distinct channel IDs with ≥1 kind=9 message in the window. + pub count: i64, +} + +/// Return distinct channel IDs with ≥1 kind=9 message in `[now - interval, now]`. +pub async fn active_channel_counts( + pool: &PgPool, + interval_sql: &'static str, +) -> Result> { + let sql = format!( + r#" + SELECT community_id, COUNT(DISTINCT channel_id) AS count + FROM events + WHERE kind = 9 + AND channel_id IS NOT NULL + AND created_at >= NOW() - INTERVAL '{interval_sql}' + AND deleted_at IS NULL + GROUP BY community_id + "# + ); + let rows = sqlx::query_as::<_, (Uuid, i64)>(sqlx::AssertSqlSafe(sql)) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(community_id, count)| CommunityActiveChannels { + community_id, + count, + }) + .collect()) +} + +/// Mapping from community UUID to host string, used by the poller to resolve +/// Prometheus label values. +#[derive(Debug)] +pub struct CommunityHost { + /// The UUID of the community. + pub id: Uuid, + /// The canonical host string for this community (used as the Prometheus label value). + pub host: String, +} + +/// Fetch all community id → host mappings in one query. +pub async fn community_hosts(pool: &PgPool) -> Result> { + let rows = sqlx::query_as::<_, (Uuid, String)>("SELECT id, host FROM communities") + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(id, host)| CommunityHost { id, host }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::CommunityId; + use nostr::Keys; + use sqlx::PgPool; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn get_pool() -> PgPool { + PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB") + } + + fn random_pubkey() -> Vec { + Keys::generate().public_key().to_bytes().to_vec() + } + + async fn make_community(pool: &PgPool) -> (Uuid, CommunityId, String) { + let id = uuid::Uuid::new_v4(); + let host = format!("usage-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(&host) + .execute(pool) + .await + .expect("insert test community"); + (id, CommunityId::from_uuid(id), host) + } + + async fn insert_user(pool: &PgPool, community_id: Uuid, pubkey: &[u8], is_agent: bool) { + if is_agent { + let owner = random_pubkey(); + // Insert owner first (FK constraint). + sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(community_id) + .bind(&owner) + .execute(pool) + .await + .expect("insert owner"); + sqlx::query( + "INSERT INTO users (community_id, pubkey, agent_owner_pubkey) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community_id) + .bind(pubkey) + .bind(&owner) + .execute(pool) + .await + .expect("insert agent user"); + } else { + sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(community_id) + .bind(pubkey) + .execute(pool) + .await + .expect("insert human user"); + } + } + + /// user_counts returns correct human/agent split and is scoped per community. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_user_counts_scoped_per_community() { + let pool = get_pool().await; + let (comm_a_uuid, _, _) = make_community(&pool).await; + let (comm_b_uuid, _, _) = make_community(&pool).await; + + // Community A: insert 2 humans first, then 1 agent whose owner is one + // of those humans (reuses existing pubkey — no extra human row). + let human1 = random_pubkey(); + let human2 = random_pubkey(); + let agent_pk = random_pubkey(); + insert_user(&pool, comm_a_uuid, &human1, false).await; + insert_user(&pool, comm_a_uuid, &human2, false).await; + // Insert agent with human1 as owner (human1 is already in users). + sqlx::query( + "INSERT INTO users (community_id, pubkey, agent_owner_pubkey) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(comm_a_uuid) + .bind(&agent_pk) + .bind(&human1) + .execute(&pool) + .await + .expect("insert agent user"); + + // Community B: 0 human, 1 agent (owner is a fresh human in comm_b). + let owner_b = random_pubkey(); + insert_user(&pool, comm_b_uuid, &owner_b, false).await; + let agent_b = random_pubkey(); + sqlx::query( + "INSERT INTO users (community_id, pubkey, agent_owner_pubkey) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(comm_b_uuid) + .bind(&agent_b) + .bind(&owner_b) + .execute(&pool) + .await + .expect("insert agent user b"); + + let counts = user_counts(&pool).await.expect("user_counts"); + + let a = counts.iter().find(|r| r.community_id == comm_a_uuid); + let b = counts.iter().find(|r| r.community_id == comm_b_uuid); + + let a = a.expect("community A row"); + assert_eq!(a.human, 2, "community A: 2 humans"); + assert_eq!(a.agent, 1, "community A: 1 agent"); + + let b = b.expect("community B row"); + assert_eq!(b.human, 1, "community B: 1 human (the agent owner)"); + assert_eq!(b.agent, 1, "community B: 1 agent"); + } + + /// Deactivated users are excluded from user_counts. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_user_counts_excludes_deactivated() { + let pool = get_pool().await; + let (comm_uuid, _, _) = make_community(&pool).await; + + let active_pk = random_pubkey(); + let deactivated_pk = random_pubkey(); + + insert_user(&pool, comm_uuid, &active_pk, false).await; + insert_user(&pool, comm_uuid, &deactivated_pk, false).await; + // Deactivate the second user. + sqlx::query( + "UPDATE users SET deactivated_at = NOW() WHERE community_id = $1 AND pubkey = $2", + ) + .bind(comm_uuid) + .bind(&deactivated_pk) + .execute(&pool) + .await + .expect("deactivate user"); + + let counts = user_counts(&pool).await.expect("user_counts"); + let row = counts + .iter() + .find(|r| r.community_id == comm_uuid) + .expect("row"); + assert_eq!(row.human, 1, "only active user counted"); + } + + /// channel_counts is scoped per community and excludes deleted channels. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_channel_counts_scoped_and_excludes_deleted() { + let pool = get_pool().await; + let (comm_uuid, comm_id, _) = make_community(&pool).await; + let owner = random_pubkey(); + insert_user(&pool, comm_uuid, &owner, false).await; + + // Insert a stream and a DM channel. + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'test-stream', 'stream', 'open', $3)", + ) + .bind(uuid::Uuid::new_v4()) + .bind(comm_uuid) + .bind(&owner) + .execute(&pool) + .await + .expect("insert stream channel"); + + let dm_id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'test-dm', 'dm', 'private', $3)", + ) + .bind(dm_id) + .bind(comm_uuid) + .bind(&owner) + .execute(&pool) + .await + .expect("insert dm channel"); + + // Soft-delete the DM. + sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE id = $1") + .bind(dm_id) + .execute(&pool) + .await + .expect("delete channel"); + + // Use comm_id to satisfy unused import warning. + let _ = comm_id; + + let counts = channel_counts(&pool).await.expect("channel_counts"); + let comm_counts: Vec<_> = counts + .iter() + .filter(|r| r.community_id == comm_uuid) + .collect(); + + // Only the stream channel should be counted. + assert_eq!(comm_counts.len(), 1); + assert_eq!(comm_counts[0].channel_type, "stream"); + assert_eq!(comm_counts[0].count, 1); + } + + /// community_hosts returns id → host mapping. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_community_hosts_returns_mapping() { + let pool = get_pool().await; + let (id, _, host) = make_community(&pool).await; + + let hosts = community_hosts(&pool).await.expect("community_hosts"); + let found = hosts.iter().find(|h| h.id == id); + assert!(found.is_some(), "inserted community not found"); + assert_eq!(found.unwrap().host, host); + } + + /// community_count reflects newly inserted communities. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_community_count_increases() { + let pool = get_pool().await; + let before = community_count(&pool).await.expect("count before"); + make_community(&pool).await; + let after = community_count(&pool).await.expect("count after"); + assert!(after > before, "count should increase after insert"); + } + + /// git_repo_counts queries git_repo_names (not git_repos) and is scoped per community. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_git_repo_counts_scoped_per_community() { + let pool = get_pool().await; + let (comm_uuid, _, _) = make_community(&pool).await; + let owner = random_pubkey(); + insert_user(&pool, comm_uuid, &owner, false).await; + let owner_hex = hex::encode(&owner); + + // Insert two repos for this community. + for repo_id in &["repo-alpha", "repo-beta"] { + sqlx::query( + "INSERT INTO git_repo_names (community_id, repo_id, owner_pubkey) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING", + ) + .bind(comm_uuid) + .bind(repo_id) + .bind(&owner_hex) + .execute(&pool) + .await + .expect("insert git repo"); + } + + let counts = git_repo_counts(&pool).await.expect("git_repo_counts"); + let comm_counts: Vec<_> = counts + .iter() + .filter(|r| r.community_id == comm_uuid) + .collect(); + + assert_eq!(comm_counts.len(), 1, "one row per community"); + assert_eq!(comm_counts[0].count, 2, "two repos"); + } + + /// active_user_counts classifies pubkeys with no users row as "unknown", + /// not "human" — the old LEFT JOIN treated NULL.agent_owner_pubkey as human. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_active_user_counts_unknown_bucket_for_profileless_poster() { + let pool = get_pool().await; + let (comm_uuid, _, _) = make_community(&pool).await; + + // One known human (has a users row). + let human_pk = random_pubkey(); + insert_user(&pool, comm_uuid, &human_pk, false).await; + + // One profileless poster (no users row at all). + let profileless_pk = random_pubkey(); + + // Insert events for both pubkeys in this community. + let event_id1 = random_pubkey(); // 32-byte id + let event_id2 = random_pubkey(); + let sig = vec![0u8; 64]; + for (pk, eid) in [(&human_pk, &event_id1), (&profileless_pk, &event_id2)] { + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at) \ + VALUES ($1, $2, $3, NOW(), 9, '[]', '', $4, NOW()) \ + ON CONFLICT DO NOTHING", + ) + .bind(comm_uuid) + .bind(eid) + .bind(pk) + .bind(&sig) + .execute(&pool) + .await + .expect("insert event"); + } + + let counts = active_user_counts(&pool, "1 day") + .await + .expect("active_user_counts"); + let row = counts.iter().find(|r| r.community_id == comm_uuid); + assert!(row.is_some(), "row for community must exist"); + let row = row.unwrap(); + assert_eq!(row.human, 1, "known human poster counts as human"); + assert_eq!(row.agent, 0, "no agents"); + assert_eq!( + row.unknown, 1, + "profileless poster must land in unknown, not human" + ); + } + + /// Regression: channel_counts returns no row for a community once all + /// channels of a type are soft-deleted. The poller zero-fills from + /// host_map, so absence from this query is the correct "zero" signal. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_channel_counts_drops_to_zero_after_last_channel_deleted() { + let pool = get_pool().await; + let (comm_uuid, _, _) = make_community(&pool).await; + let owner = random_pubkey(); + insert_user(&pool, comm_uuid, &owner, false).await; + + // Insert one stream channel. + let ch_id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'only-stream', 'stream', 'open', $3)", + ) + .bind(ch_id) + .bind(comm_uuid) + .bind(&owner) + .execute(&pool) + .await + .expect("insert channel"); + + // Sanity: row present before deletion. + let before = channel_counts(&pool).await.expect("channel_counts before"); + let before_row = before + .iter() + .find(|r| r.community_id == comm_uuid && r.channel_type == "stream"); + assert_eq!( + before_row.map(|r| r.count), + Some(1), + "1 stream channel before deletion" + ); + + // Soft-delete the channel. + sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE id = $1") + .bind(ch_id) + .execute(&pool) + .await + .expect("soft-delete channel"); + + // After deletion: no row for this community+type — query returns nothing. + let after = channel_counts(&pool).await.expect("channel_counts after"); + let after_row = after + .iter() + .find(|r| r.community_id == comm_uuid && r.channel_type == "stream"); + assert!( + after_row.is_none(), + "no stream row after last channel deleted — poller will zero-fill" + ); + } +} diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/user.rs index a4a5a9d7e8..066fb5f5c0 100644 --- a/crates/buzz-db/src/user.rs +++ b/crates/buzz-db/src/user.rs @@ -35,8 +35,12 @@ pub struct UserSearchProfile { /// Ensure a user record exists for the given pubkey (upsert). /// Creates with minimal fields if not present; no-op if already exists. -pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8]) -> Result<()> { - sqlx::query( +/// +/// Returns `true` if a new row was inserted, `false` if the user already existed. +/// The `true` case is the reliable signal for "user was just registered" — used +/// by callers to increment `buzz_users_created_total`. +pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8]) -> Result { + let result = sqlx::query( r#" INSERT INTO users (community_id, pubkey) VALUES ($1, $2) @@ -47,7 +51,7 @@ pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8] .bind(pubkey) .execute(pool) .await?; - Ok(()) + Ok(result.rows_affected() == 1) } /// Get a single user record by pubkey. diff --git a/crates/buzz-db/src/workflow.rs b/crates/buzz-db/src/workflow.rs index 2ff90280ae..9c02f162c9 100644 --- a/crates/buzz-db/src/workflow.rs +++ b/crates/buzz-db/src/workflow.rs @@ -457,13 +457,15 @@ pub async fn list_enabled_channel_workflows( pub async fn list_all_enabled_workflows(pool: &PgPool) -> Result> { let rows = sqlx::query( r#" - SELECT id, community_id, name, owner_pubkey, channel_id, definition, definition_hash, - status::text AS status, enabled, created_at, updated_at - FROM workflows - WHERE status = 'active' - AND enabled = TRUE - AND definition->'trigger'->>'on' = 'schedule' - ORDER BY created_at ASC + SELECT w.id, w.community_id, w.name, w.owner_pubkey, w.channel_id, w.definition, w.definition_hash, + w.status::text AS status, w.enabled, w.created_at, w.updated_at + FROM workflows w + JOIN communities c ON c.id = w.community_id + WHERE w.status = 'active' + AND w.enabled = TRUE + AND w.definition->'trigger'->>'on' = 'schedule' + AND c.archived_at IS NULL + ORDER BY w.created_at ASC LIMIT $1 "#, ) diff --git a/crates/buzz-dev-mcp/Cargo.toml b/crates/buzz-dev-mcp/Cargo.toml index 131a97df78..8b711b8063 100644 --- a/crates/buzz-dev-mcp/Cargo.toml +++ b/crates/buzz-dev-mcp/Cargo.toml @@ -30,11 +30,15 @@ tempfile = "3" ignore = "0.4.25" tracing = { workspace = true } tracing-subscriber = { workspace = true } +# wss:// tool calls use rustls via tokio-tungstenite/reqwest. The workspace +# pulls both ring and aws-lc-rs transitively, so install ring explicitly. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } # view_image tool: HTTP fetch (workspace reqwest is already used by buzz-cli; # adding it here is essentially free), base64 encoding, and decode/resize. reqwest = { workspace = true } base64 = "0.22" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +buzz-core = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { version = "0.31", default-features = false, features = ["signal", "process"] } @@ -46,4 +50,4 @@ nix = { version = "0.31", default-features = false, features = ["signal", "proce # required because CreateJobObjectW takes a SECURITY_ATTRIBUTES parameter; # Win32_System_Threading supplies IO_COUNTERS inside the extended-limit struct. [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading"] } diff --git a/crates/buzz-dev-mcp/src/buzz_message.rs b/crates/buzz-dev-mcp/src/buzz_message.rs new file mode 100644 index 0000000000..63a77c7940 --- /dev/null +++ b/crates/buzz-dev-mcp/src/buzz_message.rs @@ -0,0 +1,170 @@ +use crate::shell::SharedState; +use rmcp::model::{CallToolResult, Content}; +use rmcp::ErrorData; +use schemars::JsonSchema; +use serde::Deserialize; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::process::Command; + +const MAX_CONTENT_BYTES: usize = 64 * 1024; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SendMessageParams { + /// Buzz channel UUID supplied in the turn's Context section. + pub channel: String, + /// Message body to publish. + pub content: String, + /// Optional event id to reply to when the Context section requires a threaded reply. + #[serde(default)] + pub reply_to: Option, +} + +pub async fn run( + state: &SharedState, + params: SendMessageParams, +) -> Result { + if params.content.trim().is_empty() { + return Err(ErrorData::invalid_params( + "content must not be empty".to_string(), + None, + )); + } + if params.content.len() > MAX_CONTENT_BYTES { + return Err(ErrorData::invalid_params( + format!("content exceeds {MAX_CONTENT_BYTES} bytes"), + None, + )); + } + + let buzz = find_buzz(&state.shim.path_env).ok_or_else(|| { + ErrorData::internal_error("bundled Buzz CLI is unavailable".to_string(), None) + })?; + let mut command = Command::new(buzz); + command + .args([ + "messages", + "send", + "--channel", + ¶ms.channel, + "--content", + ¶ms.content, + ]) + .current_dir(&state.cwd) + .env("PATH", &state.shim.path_env) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(reply_to) = params.reply_to.as_deref() { + command.args(["--reply-to", reply_to]); + } + + let output = command.output().await.map_err(|error| { + ErrorData::internal_error(format!("failed to run Buzz CLI: {error}"), None) + })?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if output.status.success() { + let text = if stdout.is_empty() { + "Message published.".to_string() + } else { + stdout + }; + Ok(CallToolResult::success(vec![Content::text(text)])) + } else { + let detail = if stderr.is_empty() { stdout } else { stderr }; + Ok(CallToolResult::error(vec![Content::text(format!( + "Buzz message failed: {detail}" + ))])) + } +} + +fn find_buzz(path: &str) -> Option { + std::env::split_paths(path) + .map(|entry| entry.join(if cfg!(windows) { "buzz.exe" } else { "buzz" })) + .find(|candidate| candidate.is_file()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + fn make_executable(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).unwrap(); + } + + #[cfg(unix)] + #[tokio::test] + async fn publishes_with_structured_cli_arguments() { + let dir = tempfile::tempdir().unwrap(); + let buzz = dir + .path() + .join(if cfg!(windows) { "buzz.cmd" } else { "buzz" }); + let args_file = dir.path().join("args.txt"); + if cfg!(windows) { + std::fs::write( + &buzz, + format!( + "@echo off\r\n(for %%a in (%*) do @echo %%~a)>>\"{}\"\r\n", + args_file.display() + ), + ) + .unwrap(); + } else { + std::fs::write( + &buzz, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n", + args_file.display() + ), + ) + .unwrap(); + } + make_executable(&buzz); + let mut state = SharedState::new( + dir.path().to_path_buf(), + crate::shim::Shim::install().unwrap(), + ) + .unwrap(); + state.shim.path_env = dir.path().to_string_lossy().into_owned(); + let result = run( + &state, + SendMessageParams { + channel: "channel-id".into(), + content: "hello world".into(), + reply_to: Some("event-id".into()), + }, + ) + .await + .unwrap(); + assert!(!result.is_error.unwrap_or(false)); + let args = std::fs::read_to_string(args_file).unwrap(); + assert_eq!( + args.lines().collect::>(), + [ + "messages", + "send", + "--channel", + "channel-id", + "--content", + "hello world", + "--reply-to", + "event-id" + ] + ); + } + + #[test] + fn finds_bundled_buzz_on_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir + .path() + .join(if cfg!(windows) { "buzz.exe" } else { "buzz" }); + std::fs::write(&path, "test").unwrap(); + assert_eq!(find_buzz(&dir.path().to_string_lossy()), Some(path)); + } +} diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index f9fced8e81..67cbbaf402 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -10,6 +10,7 @@ use rmcp::{ use std::path::Path; use std::sync::Arc; +mod buzz_message; mod paths; mod read_file; mod rg; @@ -49,6 +50,17 @@ impl DevMcp { shell::run(&self.state, p, context.ct).await } + #[tool( + name = "buzz_send_message", + description = "Publish the user-visible reply for the current Buzz turn. Use the channel UUID and optional reply event id from the prompt Context. Every Buzz turn must call this before ending; use shell-based buzz messages send only if this tool is unavailable." + )] + async fn buzz_send_message( + &self, + Parameters(p): Parameters, + ) -> Result { + buzz_message::run(&self.state, p).await + } + #[tool( name = "read_file", description = "Read a text file and return its contents with line numbers. Returns lines in `{number}:{content}` format. Use `offset` (0-based) and `limit` (default 2000) to window into large files. Path resolved relative to workdir (defaults to server cwd). Prefer over cat/head/tail." @@ -161,6 +173,10 @@ pub fn run() -> Result<(), Box> { } async fn async_main(cmd: String) -> Result<(), Box> { + // HTTPS clients invoked through this MCP process need a Rustls provider; + // repeated installation is harmless. + let _ = rustls::crypto::ring::default_provider().install_default(); + // buzz CLI needs tokio (async HTTP client). if cmd == "buzz" { std::process::exit(buzz_cli::run_from_args(std::env::args()).await); diff --git a/crates/buzz-dev-mcp/src/shell.rs b/crates/buzz-dev-mcp/src/shell.rs index 2dde39f3d9..b7df4c6747 100644 --- a/crates/buzz-dev-mcp/src/shell.rs +++ b/crates/buzz-dev-mcp/src/shell.rs @@ -393,44 +393,33 @@ fn resolve_bash(_path_env: &str) -> Result<(PathBuf, String), String> { /// WITHOUT the System32 exclusion — the operator explicitly chose this shell, /// and cmd.exe/powershell.exe live in System32 legitimately. /// 2. `GIT_BASH` env override — legacy escape hatch (kept for back-compat). -/// 3. Installed Git for Windows (fast path when the user has Git). -/// 4. PATH scan for `bash.exe`, EXCLUDING System32 (so we never resolve WSL's -/// `bash.exe` — the `0x8007072c` hazard). +/// 3. `bash.exe` on PATH, excluding System32 so we never resolve WSL's launcher. +/// 4. `git.exe` on PATH → its sibling `..\\bin\\bash.exe`. Git for Windows's +/// recommended "Git from the command line" option adds `Git\\cmd` to PATH, +/// not `Git\\bin`, so this is the normal post-install route. +/// 5. Standard `ProgramFiles`, `ProgramFiles(x86)`, and `LocalAppData` paths +/// when the child inherited their parent environment. +/// 6. Git for Windows's machine then user registry `InstallPath`. /// /// Returns `(resolved_path, display_name)`. The display name is derived from the /// resolved path, guaranteeing the dialect hint and the spawned shell agree. /// -/// The previously-bundled PortableGit fallback (probe 3 in the old order) has -/// been removed: Git for Windows is a documented host prerequisite, and shipping -/// a multi-hundred-MB runtime contradicts the VISION_AGENT.md "minimal" principle. -/// /// No bash found -> actionable error pointing at the prerequisite. #[cfg(windows)] fn resolve_bash(path_env: &str) -> Result<(PathBuf, String), String> { - // BUZZ_SHELL: explicit operator override — any shell, including cmd or PowerShell. - // Bare command names are resolved WITHOUT System32 exclusion: the operator - // chose this shell on purpose, and cmd/pwsh legitimately live in System32. if let Some(raw) = std::env::var_os("BUZZ_SHELL") { let p = PathBuf::from(&raw); - // Absolute / rooted path: must exist as a file. if p.components().count() > 1 || p.has_root() { if p.is_file() { let name = shell_name_from_path(&p); return Ok((p, name)); } - // Non-existent absolute path: fall through, do NOT report this shell. - } else { - // Bare command name (e.g. "pwsh", "cmd"): scan PATH, NO System32 - // exclusion — the operator explicitly wants this shell. - if let Some(found) = scan_path_for_command(&p, path_env, None) { - let name = shell_name_from_path(&found); - return Ok((found, name)); - } - // Not found on PATH: fall through. + } else if let Some(found) = scan_path_for_command(&p, path_env, None) { + let name = shell_name_from_path(&found); + return Ok((found, name)); } } - // GIT_BASH: legacy override kept for back-compat. if let Some(p) = std::env::var_os("GIT_BASH").map(PathBuf::from) { if p.is_file() { let name = shell_name_from_path(&p); @@ -438,33 +427,138 @@ fn resolve_bash(path_env: &str) -> Result<(PathBuf, String), String> { } } - for root in ["ProgramFiles", "LocalAppData"] { - if let Some(base) = std::env::var_os(root) { - let candidate = match root { - "LocalAppData" => PathBuf::from(&base).join("Programs").join("Git"), - _ => PathBuf::from(&base).join("Git"), + let system_root = std::env::var_os("SystemRoot").map(PathBuf::from); + if let Some(p) = scan_path_for_bash(path_env, system_root.as_deref()) { + return Ok((p, "bash".to_string())); + } + + if let Some(git) = scan_path_for_command(Path::new("git.exe"), path_env, None) { + if let Some(bash) = bash_from_git(&git) { + return Ok((bash, "bash".to_string())); + } + } + + if let Some(bash) = git_bash_from_standard_paths() { + return Ok((bash, "bash".to_string())); + } + + if let Some(bash) = git_bash_from_registry() { + return Ok((bash, "bash".to_string())); + } + + Err( + "Git for Windows (Git Bash) is required but was not found. Checked \\ + BUZZ_SHELL, GIT_BASH, bash.exe and git.exe on PATH, the standard Git install locations, \\ + and HKLM/HKCU\\\\SOFTWARE\\\\GitForWindows. Git's \"Cmd\" PATH option adds \\ + Git\\\\cmd\\\\git.exe but not Git\\\\bin\\\\bash.exe; Buzz normally derives Git Bash from that git.exe. \\ + Install it from https://git-scm.com/download/win and select \"Git from the command line \\ + and also from 3rd-party software\", then relaunch Buzz. You can also set \\ + BUZZ_SHELL to a shell executable." + .into(), + ) +} + +/// Git for Windows puts `git.exe` in `\\cmd`; the MSYS bash binary is +/// its stable sibling at `\\bin\\bash.exe`. +#[cfg(windows)] +fn bash_from_git(git: &Path) -> Option { + let install_root = git.parent()?.parent()?; + let bash = install_root.join("bin").join("bash.exe"); + bash.is_file().then_some(bash) +} + +/// Probe machine and per-user Git for Windows registry keys after the standard +/// install-location fallback has been exhausted. +#[cfg(windows)] +#[allow(unsafe_code)] +fn git_bash_from_registry() -> Option { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS}; + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, + KEY_READ, + }; + + const KEY: &str = "SOFTWARE\\GitForWindows"; + const VALUE: &str = "InstallPath"; + let key: Vec = KEY.encode_utf16().chain(Some(0)).collect(); + let value: Vec = VALUE.encode_utf16().chain(Some(0)).collect(); + + // SAFETY: Inputs are null-terminated UTF-16 for the duration of each call, + // and every successfully opened handle is closed before trying the next hive. + unsafe { + for hive in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] { + let mut handle = std::ptr::null_mut(); + if RegOpenKeyExW(hive, key.as_ptr(), 0, KEY_READ, &mut handle) != ERROR_SUCCESS { + continue; } - .join("bin") - .join("bash.exe"); - if candidate.is_file() { - return Ok((candidate, "bash".to_string())); + + let mut byte_len = 0; + let status = RegQueryValueExW( + handle, + value.as_ptr(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut byte_len, + ); + if (status != ERROR_SUCCESS && status != ERROR_MORE_DATA) || byte_len == 0 { + RegCloseKey(handle); + continue; + } + + let mut data = vec![0u16; (byte_len as usize).div_ceil(2)]; + let status = RegQueryValueExW( + handle, + value.as_ptr(), + std::ptr::null(), + std::ptr::null_mut(), + data.as_mut_ptr().cast(), + &mut byte_len, + ); + RegCloseKey(handle); + if status != ERROR_SUCCESS { + continue; + } + + while data.last() == Some(&0) { + data.pop(); + } + let bash = PathBuf::from(OsString::from_wide(&data)) + .join("bin") + .join("bash.exe"); + if bash.is_file() { + return Some(bash); } } } - // PATH scan for bash.exe, skipping System32 to avoid WSL's bash.exe launcher. - if let Some(p) = scan_path_for_bash(path_env, std::env::var_os("SystemRoot").map(PathBuf::from)) - { - return Ok((p, "bash".to_string())); - } + None +} - Err( - "Git for Windows (git bash) is required but was not found.\n\ - Install it from https://git-scm.com/download/win and re-launch Buzz,\n\ - or set BUZZ_SHELL to the path of any bash-compatible executable (or a bare\n\ - command name like cmd or pwsh if it is on PATH)." - .into(), - ) +#[cfg(windows)] +fn git_bash_from_standard_paths() -> Option { + git_bash_from_standard_path_bases([ + std::env::var_os("ProgramFiles").map(PathBuf::from), + std::env::var_os("ProgramFiles(x86)").map(PathBuf::from), + std::env::var_os("LocalAppData").map(PathBuf::from), + ]) +} + +#[cfg(windows)] +fn git_bash_from_standard_path_bases( + [program_files, program_files_x86, local_app_data]: [Option; 3], +) -> Option { + [ + program_files.map(|base| base.join("Git")), + program_files_x86.map(|base| base.join("Git")), + local_app_data.map(|base| base.join("Programs").join("Git")), + ] + .into_iter() + .flatten() + .map(|install_root| install_root.join("bin").join("bash.exe")) + .find(|bash| bash.is_file()) } /// True if `dir` is `root` or lives under it, comparing path components @@ -493,8 +587,8 @@ fn is_under_dir(dir: &Path, root: &Path) -> bool { /// `System32\bash.exe`. PATH is parsed with `std::env::split_paths` (never a /// hand-split on ';') so it matches exactly what the spawned child would see. #[cfg(windows)] -fn scan_path_for_bash(path_env: &str, system_root: Option) -> Option { - scan_path_for_command(Path::new("bash.exe"), path_env, system_root.as_deref()) +fn scan_path_for_bash(path_env: &str, system_root: Option<&Path>) -> Option { + scan_path_for_command(Path::new("bash.exe"), path_env, system_root) } /// Scan `path_env` for `name` (or `name.exe` on Windows if `name` has no @@ -1130,6 +1224,53 @@ mod windows_resolver_tests { // Err is also acceptable (no Git on test host). } + #[test] + fn git_cmd_on_path_resolves_sibling_git_bash() { + let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner()); + let dir = tempdir().expect("tempdir"); + let git = dir.path().join("Git").join("cmd").join("git.exe"); + let bash = dir.path().join("Git").join("bin").join("bash.exe"); + touch(&git); + touch(&bash); + + let path_env = env::join_paths([git.parent().expect("cmd dir")]).expect("join"); + let old_buzz_shell = env::var_os("BUZZ_SHELL"); + let old_git_bash = env::var_os("GIT_BASH"); + env::remove_var("BUZZ_SHELL"); + env::remove_var("GIT_BASH"); + + let result = resolve_bash(path_env.to_str().expect("utf8")); + + match old_buzz_shell { + Some(value) => env::set_var("BUZZ_SHELL", value), + None => env::remove_var("BUZZ_SHELL"), + } + match old_git_bash { + Some(value) => env::set_var("GIT_BASH", value), + None => env::remove_var("GIT_BASH"), + } + + assert_eq!( + result + .expect("Git cmd PATH entry should resolve its sibling bash") + .0, + bash + ); + } + + #[test] + fn program_files_x86_git_bash_is_found() { + let dir = tempdir().expect("tempdir"); + let program_files_x86 = dir.path().join("Program Files (x86)"); + let bash = program_files_x86.join("Git").join("bin").join("bash.exe"); + touch(&bash); + + assert_eq!( + git_bash_from_standard_path_bases([None, Some(program_files_x86), None]), + Some(bash) + ); + } + #[test] fn path_scan_skips_system32_and_returns_absolute() { // A bash.exe under %SystemRoot% (where WSL's launcher lives) must be @@ -1144,11 +1285,8 @@ mod windows_resolver_tests { env::join_paths([sys_root.path().join("System32"), real.path().to_path_buf()]) .expect("join"); - let found = scan_path_for_bash( - path_env.to_str().expect("utf8"), - Some(sys_root.path().to_path_buf()), - ) - .expect("bash found outside System32"); + let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path())) + .expect("bash found outside System32"); assert!(found.is_absolute()); assert!(!found.starts_with(sys_root.path())); assert_eq!(found, real_bash); @@ -1161,10 +1299,7 @@ mod windows_resolver_tests { touch(&sys_root.path().join("System32").join("bash.exe")); let path_env = env::join_paths([sys_root.path().join("System32")]).expect("join"); - let found = scan_path_for_bash( - path_env.to_str().expect("utf8"), - Some(sys_root.path().to_path_buf()), - ); + let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path())); assert!(found.is_none()); } @@ -1182,7 +1317,7 @@ mod windows_resolver_tests { touch(&sys32.join("bash.exe")); let path_env = env::join_paths([sys32]).expect("join"); - let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(root)); + let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(&root)); assert!( found.is_none(), "case-divergent System32 must still be excluded" @@ -1204,11 +1339,8 @@ mod windows_resolver_tests { let path_env = env::join_paths([real.path().to_path_buf()]).expect("join"); let sys_root = tempdir().expect("sysroot"); // empty — no System32 here - let found = scan_path_for_bash( - path_env.to_str().expect("utf8"), - Some(sys_root.path().to_path_buf()), - ) - .expect("bash on PATH must be found"); + let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path())) + .expect("bash on PATH must be found"); assert_eq!(found, real_bash); } } diff --git a/crates/buzz-dev-mcp/src/view_image.rs b/crates/buzz-dev-mcp/src/view_image.rs index c3e6ed112f..441338ab12 100644 --- a/crates/buzz-dev-mcp/src/view_image.rs +++ b/crates/buzz-dev-mcp/src/view_image.rs @@ -48,6 +48,9 @@ pub(crate) const MAX_PIXELS: u64 = 64 * 1024 * 1024; pub(crate) const MAX_DECODER_ALLOC: u64 = 256 * 1024 * 1024; /// Connect + read timeout for URL fetches. const FETCH_TIMEOUT: Duration = Duration::from_secs(10); +/// Lifetime of a Blossom `t=get` read token for relay media fetches. +/// Matches the desktop client's `MEDIA_GET_AUTH_EXPIRY_SECS`. +const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; /// Build the decoder allocation cap. Centralised so the resize path uses the /// same value tests can reason about. @@ -221,23 +224,140 @@ fn decode_data_url(src: &str) -> Result, String> { .map_err(|e| format!("data: URL base64 decode failed: {e}")) } +/// True when `target` is a relay-hosted Blossom media URL: an http(s) URL +/// whose path is under `/media/` and whose authority (host + effective port) +/// matches the configured relay URL. The relay URL may use ws/wss schemes — +/// the url crate's known default ports (ws=80/wss=443) make those compare +/// equal to their http/https counterparts. +/// +/// Safety contract: this is the *only* gate that decides whether we attach a +/// signed auth header. It must never match arbitrary third-party origins, +/// where the bearer token would leak. +fn is_relay_media_url(target: &reqwest::Url, relay: &reqwest::Url) -> bool { + if !matches!(target.scheme(), "http" | "https") { + return false; + } + if !target.path().starts_with("/media/") { + return false; + } + target.host_str().is_some() + && target.host_str() == relay.host_str() + && target.port_or_known_default() == relay.port_or_known_default() +} + +/// Sign a Blossom (BUD-01) `t=get` authorization event, server-scoped to +/// `authority` (`host[:port]`), and return the full `Authorization` header +/// value. Mirrors the desktop client's signer: kind 24242, `t=get`, +/// `expiration`, `server` tag — no `x` tag (BUD-01 allows x OR server). +fn sign_media_get_auth(keys: &nostr::Keys, authority: &str) -> Result { + use nostr::{EventBuilder, JsonUtil, Kind, Tag, Timestamp}; + let now = Timestamp::now().as_secs(); + let tags = vec![ + Tag::parse(["t", "get"]).map_err(|e| e.to_string())?, + Tag::parse([ + "expiration", + &(now + MEDIA_GET_AUTH_EXPIRY_SECS).to_string(), + ]) + .map_err(|e| e.to_string())?, + Tag::parse(["server", authority]).map_err(|e| e.to_string())?, + ]; + let event = EventBuilder::new(Kind::from(24242), "Get buzz-media") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| e.to_string())?; + Ok(format!( + "Nostr {}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + )) +} + +/// `host[:port]` for the `server` tag, normalized the same way relay tenant +/// lookup normalizes Host/RELAY_URL. Explicit non-default ports and IPv6 +/// brackets are preserved; default ports collapse to the bare host. +fn server_authority(url: &reqwest::Url) -> Option { + let authority = buzz_core::tenant::relay_url_authority(url.as_str()); + if authority.is_empty() { + None + } else { + Some(authority) + } +} + +/// Mint a `t=get` Authorization header for `url` when it is relay-hosted +/// media and `BUZZ_PRIVATE_KEY` is available; `None` otherwise. +/// +/// Fail-open by design: while the relay's media-read-auth flag is off, an +/// unauthenticated request still succeeds, so a missing/invalid key degrades +/// to an unsigned fetch instead of an error. Once the flag is on, the fetch +/// 403s and the error path below names the missing key. +fn relay_media_get_auth(url: &reqwest::Url) -> Option { + let relay = std::env::var("BUZZ_RELAY_URL").ok()?; + let relay = reqwest::Url::parse(&relay).ok()?; + if !is_relay_media_url(url, &relay) { + return None; + } + let key = std::env::var("BUZZ_PRIVATE_KEY").ok()?; + let keys = match nostr::Keys::parse(&key) { + Ok(k) => k, + Err(e) => { + tracing::warn!("BUZZ_PRIVATE_KEY invalid; fetching relay media unauthenticated: {e}"); + return None; + } + }; + let authority = server_authority(url)?; + match sign_media_get_auth(&keys, &authority) { + Ok(header) => Some(header), + Err(e) => { + tracing::warn!("media get auth signing failed; fetching unauthenticated: {e}"); + None + } + } +} + /// Fetch an http(s) URL with a streaming read and a hard byte cap. /// Refuses up-front if `Content-Length` advertises more than the cap. +/// Relay-hosted `/media/` URLs get a signed Blossom `t=get` header when +/// `BUZZ_RELAY_URL` + `BUZZ_PRIVATE_KEY` are configured. async fn fetch_url(url: &str) -> Result, ErrorData> { - let client = reqwest::Client::builder() + let parsed = reqwest::Url::parse(url) + .map_err(|e| invalid_params(format!("invalid URL: {url} ({e})")))?; + let auth = relay_media_get_auth(&parsed); + let mut client_builder = reqwest::Client::builder() .connect_timeout(FETCH_TIMEOUT) - .timeout(FETCH_TIMEOUT) + .timeout(FETCH_TIMEOUT); + if auth.is_some() { + // Do not forward Buzz auth headers to a redirect target. reqwest strips + // Authorization cross-host, but `x-auth-tag` is not on reqwest's + // sensitive-header list. + client_builder = client_builder.redirect(reqwest::redirect::Policy::none()); + } + let client = client_builder .build() .map_err(|e| ErrorData::internal_error(format!("http client init failed: {e}"), None))?; - let resp = client - .get(url) + let mut req = client.get(parsed); + let authed = auth.is_some(); + if let Some(header) = auth { + req = req.header("Authorization", header); + if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { + if !auth_tag.trim().is_empty() { + req = req.header("x-auth-tag", auth_tag); + } + } + } + let resp = req .send() .await .map_err(|e| ErrorData::internal_error(format!("fetch failed: {url} ({e})"), None))?; if !resp.status().is_success() { + let status = resp.status(); + if matches!(status.as_u16(), 401 | 403) && !authed { + return Err(invalid_params(format!( + "fetch {url} returned HTTP {status} — this relay requires authenticated media \ + reads; set BUZZ_PRIVATE_KEY (and BUZZ_RELAY_URL) to a member identity" + ))); + } return Err(invalid_params(format!( - "fetch {url} returned HTTP {}", - resp.status() + "fetch {url} returned HTTP {status}" ))); } if let Some(len) = resp.content_length() { @@ -934,4 +1054,83 @@ mod tests { assert_eq!(sniff_mime(&webp).unwrap(), "image/webp"); sniff_mime(b"not-an-image").unwrap_err(); } + + fn u(s: &str) -> reqwest::Url { + reqwest::Url::parse(s).unwrap() + } + + #[test] + fn relay_media_url_matches_relay_host_only() { + let relay = u("http://relay.example.com:3000"); + // Exact authority + /media/ path → match. + assert!(is_relay_media_url( + &u("http://relay.example.com:3000/media/abc.png"), + &relay + )); + // Different host / port / path → no match (token must not leak). + assert!(!is_relay_media_url( + &u("http://evil.example.com:3000/media/abc.png"), + &relay + )); + assert!(!is_relay_media_url( + &u("http://relay.example.com:4000/media/abc.png"), + &relay + )); + assert!(!is_relay_media_url( + &u("http://relay.example.com:3000/other/abc.png"), + &relay + )); + // Path prefix must be a real segment boundary under /media/. + assert!(!is_relay_media_url( + &u("http://relay.example.com:3000/mediafake/abc.png"), + &relay + )); + } + + #[test] + fn relay_media_url_ws_scheme_and_default_ports() { + // wss relay ↔ https media on default ports compare equal. + assert!(is_relay_media_url( + &u("https://relay.example.com/media/abc.png"), + &u("wss://relay.example.com") + )); + assert!(is_relay_media_url( + &u("http://localhost:3000/media/abc.png"), + &u("ws://localhost:3000") + )); + // http media against a wss (443) relay must NOT match. + assert!(!is_relay_media_url( + &u("http://relay.example.com/media/abc.png"), + &u("wss://relay.example.com") + )); + } + + #[test] + fn media_get_auth_header_shape() { + use nostr::JsonUtil; + let keys = nostr::Keys::generate(); + let header = sign_media_get_auth(&keys, "localhost:3000").unwrap(); + let b64 = header.strip_prefix("Nostr ").expect("Nostr scheme prefix"); + let json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .unwrap(); + let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); + + assert_eq!(event.kind, nostr::Kind::from(24242)); + event.verify().expect("valid signature"); + + let tag = |name: &str| -> Option { + event.tags.iter().find_map(|t| { + let v = t.as_slice(); + (v.first().map(String::as_str) == Some(name)).then(|| v[1].clone()) + }) + }; + assert_eq!(tag("t").as_deref(), Some("get")); + assert_eq!(tag("server").as_deref(), Some("localhost:3000")); + // Server-scoped token: no x tag (BUD-01 allows x OR server). + assert!(tag("x").is_none()); + let expiration: u64 = tag("expiration").unwrap().parse().unwrap(); + let now = nostr::Timestamp::now().as_secs(); + assert!(expiration > now && expiration <= now + MEDIA_GET_AUTH_EXPIRY_SECS); + } } diff --git a/crates/buzz-media/Cargo.toml b/crates/buzz-media/Cargo.toml index 8f5d206d71..aee5fdbdf4 100644 --- a/crates/buzz-media/Cargo.toml +++ b/crates/buzz-media/Cargo.toml @@ -18,6 +18,7 @@ thiserror = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } chrono = { workspace = true } +ulid = "1" axum = { workspace = true } s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] } infer = "0.19" diff --git a/crates/buzz-media/src/auth.rs b/crates/buzz-media/src/auth.rs index 5b3358734e..c6fff2be47 100644 --- a/crates/buzz-media/src/auth.rs +++ b/crates/buzz-media/src/auth.rs @@ -2,18 +2,35 @@ use crate::error::MediaError; -/// Verify kind:24242 event validity per BUD-11: +/// Blossom kind:24242 verbs Buzz currently accepts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlossomVerb { + Upload, + Get, +} + +impl BlossomVerb { + fn as_str(self) -> &'static str { + match self { + Self::Upload => "upload", + Self::Get => "get", + } + } +} + +/// Verify common kind:24242 Blossom auth event validity: /// 1. Schnorr signature /// 2. kind == 24242 -/// 3. `t` tag == "upload" +/// 3. `t` tag matches `verb` /// 4. `expiration` tag in the future /// 5. `created_at` in the past (with 5s clock-skew tolerance) /// 6. If `server` tags present, our domain must appear in at least one /// -/// Does NOT check the `x` tag — that requires the body hash, computed later. -/// Call this BEFORE trusting the event's pubkey for scope resolution. -pub fn verify_blossom_auth_event( +/// Does NOT check verb-specific scope tags (`x` for upload, `x` OR `server` +/// for get). Call this BEFORE trusting the event's pubkey for scope resolution. +pub fn verify_blossom_auth_event_for_verb( auth_event: &nostr::Event, + verb: BlossomVerb, server_domain: Option<&str>, max_age_secs: u64, ) -> Result<(), MediaError> { @@ -42,7 +59,7 @@ pub fn verify_blossom_auth_event( match kind.as_str() { "t" => { if let Some(v) = tag.content() { - if v != "upload" { + if v != verb.as_str() { return Err(MediaError::InvalidAuthVerb); } found_t = true; @@ -123,6 +140,18 @@ pub fn verify_blossom_auth_event( Ok(()) } +/// Verify common upload auth event validity. +/// +/// Kept as the upload-shaped public wrapper for existing callers; new verb-aware +/// code should prefer [`verify_blossom_auth_event_for_verb`]. +pub fn verify_blossom_auth_event( + auth_event: &nostr::Event, + server_domain: Option<&str>, + max_age_secs: u64, +) -> Result<(), MediaError> { + verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, max_age_secs) +} + /// Normalize a Blossom `server` tag value (or a bound tenant host) into the /// canonical host form used as the community lookup key. /// @@ -149,7 +178,12 @@ pub fn verify_blossom_upload_auth( server_domain: Option<&str>, max_age_secs: u64, ) -> Result<(), MediaError> { - verify_blossom_auth_event(auth_event, server_domain, max_age_secs)?; + verify_blossom_auth_event_for_verb( + auth_event, + BlossomVerb::Upload, + server_domain, + max_age_secs, + )?; // At least one x tag must match the body sha256 (BUD-11 §6) let has_matching_x = auth_event @@ -164,6 +198,46 @@ pub fn verify_blossom_upload_auth( Ok(()) } +/// Verify a kind:24242 Blossom get auth event for one requested blob. +/// +/// BUD-01 permits either blob-scoped authorization (`x` tag matches `sha256`) +/// or server-scoped authorization (`server` tag matches this relay host). The +/// latter intentionally grants reads for all blobs on the host until expiration; +/// callers must still apply relay membership after this verifier returns. +pub fn verify_blossom_get_auth( + auth_event: &nostr::Event, + sha256: &str, + server_domain: Option<&str>, + max_age_secs: u64, +) -> Result<(), MediaError> { + verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Get, server_domain, max_age_secs)?; + + let has_matching_x = auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(sha256))); + + let has_matching_server = match server_domain { + Some(domain) => { + let want = normalize_server_host(domain); + auth_event.tags.iter().any(|tag| { + tag.kind().to_string() == "server" + && tag + .content() + .map(|value| normalize_server_host(value) == want) + .unwrap_or(false) + }) + } + None => false, + }; + + if !has_matching_x && !has_matching_server { + return Err(MediaError::InsufficientScope); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -199,6 +273,104 @@ mod tests { assert!(verify_blossom_auth_event(&event, None, 600).is_ok()); } + fn build_get_auth(keys: &Keys, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::from(24242), "Get buzz-media") + .tags(tags) + .sign_with_keys(keys) + .unwrap() + } + + #[test] + fn test_verify_get_accepts_matching_x_without_server_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let event = build_get_auth( + &keys, + vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ], + ); + + assert!(verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600).is_ok()); + } + + #[test] + fn test_verify_get_accepts_matching_server_without_x_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let event = build_get_auth( + &keys, + vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["server", "https://Relay.Example./media/ignored"]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ], + ); + + assert!(verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600).is_ok()); + } + + #[test] + fn test_verify_get_rejects_upload_verb() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let event = build_valid_auth(&keys, &sha256); + + assert!(matches!( + verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600), + Err(MediaError::InvalidAuthVerb) + )); + } + + #[test] + fn test_verify_get_requires_x_or_server_scope() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let other_hash = "b".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let event = build_get_auth( + &keys, + vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", &other_hash]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ], + ); + + assert!(matches!( + verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600), + Err(MediaError::InsufficientScope) + )); + } + + #[test] + fn test_verify_get_rejects_wrong_server_scope() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let event = build_get_auth( + &keys, + vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["server", "other.example"]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ], + ); + + assert!(matches!( + verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600), + Err(MediaError::ServerMismatch) + )); + } + #[test] fn test_verify_hash_mismatch() { let keys = Keys::generate(); diff --git a/crates/buzz-media/src/config.rs b/crates/buzz-media/src/config.rs index 5b6ca96dff..047c08475e 100644 --- a/crates/buzz-media/src/config.rs +++ b/crates/buzz-media/src/config.rs @@ -43,6 +43,22 @@ pub struct MediaConfig { pub max_file_bytes: u64, /// Public base URL for media URLs in BlobDescriptor (must include `/media` path). pub public_base_url: String, + /// Whether to write per-upload-event records under `_uploads/` + /// (moderation side channel). Off by default; set via + /// `BUZZ_MEDIA_UPLOAD_RECORDS=true`. + #[serde(default)] + pub upload_records_enabled: bool, + /// Trusted edge header to read the uploader's public IP from (e.g. + /// `cf-connecting-ip`). Unset (default) → no IP is read or recorded. + /// Only consulted when `upload_records_enabled` is true; the value is + /// validated as a public IP and dropped otherwise (fail-empty). + #[serde(default)] + pub upload_ip_header: Option, + /// Trusted edge header to read the uploader's source port from. Standard + /// edges don't emit one, so this is usually unset; a port is only + /// recorded alongside a valid IP. + #[serde(default)] + pub upload_port_header: Option, } impl MediaConfig { @@ -72,6 +88,100 @@ impl MediaConfig { if self.max_file_bytes == 0 { return Err("max_file_bytes must be > 0".to_string()); } + // Fail startup on incoherent collection config instead of silently + // recording nothing — an operator who set an IP header believes they + // are meeting a reporting obligation. + if self.upload_ip_header.is_some() && !self.upload_records_enabled { + return Err( + "BUZZ_MEDIA_UPLOAD_IP_HEADER is set but BUZZ_MEDIA_UPLOAD_RECORDS is not \ + enabled — the IP would never be recorded. Enable upload records or unset \ + the header." + .to_string(), + ); + } + if self.upload_port_header.is_some() && self.upload_ip_header.is_none() { + return Err( + "BUZZ_MEDIA_UPLOAD_PORT_HEADER is set without BUZZ_MEDIA_UPLOAD_IP_HEADER — \ + a port is only recorded alongside an IP. Set the IP header or unset the \ + port header." + .to_string(), + ); + } + for (name, value) in [ + ("BUZZ_MEDIA_UPLOAD_IP_HEADER", &self.upload_ip_header), + ("BUZZ_MEDIA_UPLOAD_PORT_HEADER", &self.upload_port_header), + ] { + if let Some(h) = value { + if axum::http::HeaderName::from_bytes(h.as_bytes()).is_err() { + return Err(format!("{name} is not a valid header name: {h:?}")); + } + } + } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::MediaConfig; + + fn valid_config() -> MediaConfig { + MediaConfig { + s3_endpoint: "http://localhost:9000".to_string(), + s3_access_key: "k".to_string(), + s3_secret_key: "s".to_string(), + s3_bucket: "buzz-media".to_string(), + s3_region: "us-east-1".to_string(), + max_image_bytes: 1, + max_gif_bytes: 1, + max_video_bytes: 1, + max_file_bytes: 1, + public_base_url: "http://localhost:3000/media".to_string(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + } + } + + #[test] + fn upload_record_knobs_default_off_and_validate() { + assert!(valid_config().validate().is_ok()); + + let mut on = valid_config(); + on.upload_records_enabled = true; + assert!(on.validate().is_ok()); + + on.upload_ip_header = Some("cf-connecting-ip".to_string()); + assert!(on.validate().is_ok()); + + on.upload_port_header = Some("x-client-port".to_string()); + assert!(on.validate().is_ok()); + } + + #[test] + fn ip_header_without_records_fails_startup() { + // An operator who set the header believes IPs are being recorded — + // fail loudly instead of silently collecting nothing. + let mut cfg = valid_config(); + cfg.upload_ip_header = Some("cf-connecting-ip".to_string()); + assert!(cfg.validate().is_err()); + } + + #[test] + fn port_header_without_ip_header_fails_startup() { + let mut cfg = valid_config(); + cfg.upload_records_enabled = true; + cfg.upload_port_header = Some("x-client-port".to_string()); + assert!(cfg.validate().is_err()); + } + + #[test] + fn malformed_header_names_fail_startup() { + let mut cfg = valid_config(); + cfg.upload_records_enabled = true; + for bad in ["with space", "colon:name", "bad/header", "bad,header", ""] { + cfg.upload_ip_header = Some(bad.to_string()); + assert!(cfg.validate().is_err(), "should reject header name {bad:?}"); + } + } +} diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index f382c21f83..e62972d587 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -9,6 +9,7 @@ pub mod storage; pub mod thumbnail; pub mod types; pub mod upload; +pub mod upload_record; pub mod validation; pub use config::MediaConfig; @@ -16,4 +17,8 @@ pub use error::MediaError; pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage}; pub use types::BlobDescriptor; pub use upload::{process_file_upload, process_upload, process_video_upload}; +pub use upload_record::{ + parse_port, parse_public_ip, upload_record_key, UploadAttribution, UploadNetworkInfo, + UploadRecord, UPLOAD_RECORD_VERSION, +}; pub use validation::{serve_inline, validate_video_file, VideoMeta}; diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index 743ebdcb06..a469826df9 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -256,6 +256,9 @@ mod tests { max_video_bytes: 524_288_000, max_file_bytes: 104_857_600, public_base_url: "http://localhost:3000/media".to_string(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, } } diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index cbf0760f0e..32f8d09baf 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -11,6 +11,7 @@ use crate::error::MediaError; use crate::storage::{BlobMeta, MediaStorage}; use crate::thumbnail::generate_image_metadata_sync; use crate::types::BlobDescriptor; +use crate::upload_record::{record_upload_event, UploadAttribution, UploadEventFacts}; use crate::validation::{ mime_to_ext, validate_content, validate_file_content, validate_video_file, }; @@ -21,30 +22,53 @@ use crate::validation::{ /// - `validate`: a CPU-bound check (run inside `spawn_blocking`) that returns /// the `(mime, ext)` pair for the body. Images derive `ext` from the MIME; /// generic files get both from the deny-list validator. -/// - `store_metadata`: stores the sidecar (and any derived artifacts such as a -/// thumbnail) and returns the resulting [`BlobMeta`]. Images run the full -/// image-metadata pipeline; generic files write a minimal sidecar. It -/// receives the already-computed `(sha256, ext, mime, uploaded_at)` so no -/// work is repeated. +/// - `prepare_metadata`: builds metadata and stores any derived artifacts such +/// as a thumbnail, but deliberately does not write the sidecar. The sidecar +/// is the media serve gate and is published only after the moderation record +/// succeeds. It receives the already-computed +/// `(sha256, ext, mime, uploaded_at)` so no work is repeated. /// /// Everything else — hash, Blossom auth (10-minute window), content-addressed /// key, the both-exist idempotency short-circuit, blob store, orphan-blob /// handling, and descriptor build — is common. The streaming video path stays /// separate (see [`process_video_upload`]) because it never buffers in RAM. -async fn process_buffered_upload( - storage: &MediaStorage, - config: &MediaConfig, - ctx: &TenantContext, - auth_event: &nostr::Event, +/// +/// `attribution` is `Some` when per-event upload records are enabled +/// (`BUZZ_MEDIA_UPLOAD_RECORDS`): a record is then written for **every** +/// accepted upload — including the idempotent short-circuit, which does no +/// blob PUT and would otherwise be invisible to the moderation pipeline. +/// For fresh uploads, the record is written after the blob and derived +/// artifacts but before the sidecar. This preserves both contracts: record +/// existence implies referenced objects are readable, while a record failure +/// cannot publish media without triggering moderation. +struct BufferedUploadInput<'a> { + storage: &'a MediaStorage, + config: &'a MediaConfig, + ctx: &'a TenantContext, + auth_event: &'a nostr::Event, body: Bytes, + attribution: Option, +} + +async fn process_buffered_upload( + input: BufferedUploadInput<'_>, validate: V, - store_metadata: M, + prepare_metadata: M, ) -> Result where V: FnOnce(&Bytes, &MediaConfig) -> Result<(String, String), MediaError> + Send + 'static, M: FnOnce(MetadataInput) -> Fut, Fut: std::future::Future>, { + let BufferedUploadInput { + storage, + config, + ctx, + auth_event, + body, + attribution, + } = input; + // CPU-bound: validate content, compute hash, verify auth. let auth = auth_event.clone(); let bytes = body.clone(); @@ -72,6 +96,26 @@ where let blob_exists = storage.head(&key).await?; if sidecar_exists && blob_exists { let meta = storage.get_sidecar(ctx, &sha256).await?; + // A re-upload of known bytes is still a distinct upload *event*: no + // blob PUT happens, so without this record the uploader would be + // invisible to the moderation pipeline (and takedown re-uploads + // would go unscanned). + if let Some(attribution) = &attribution { + record_upload_event( + storage, + ctx, + &auth_event.pubkey, + attribution, + UploadEventFacts { + sha256: &sha256, + ext: &ext, + mime: &mime, + size: body.len() as u64, + uploaded_at: chrono::Utc::now().timestamp(), + }, + ) + .await?; + } return Ok(build_descriptor( config, &sha256, @@ -95,30 +139,52 @@ where // matching sidecar after a grace period. storage.put(&key, &body, &mime).await?; - let meta_result = store_metadata(MetadataInput { + let meta = match prepare_metadata(MetadataInput { sha256: sha256.clone(), ext: ext.clone(), mime: mime.clone(), body: body.clone(), uploaded_at, }) - .await; - - match meta_result { - Ok(meta) => Ok(build_descriptor( - config, - &sha256, - &ext, - &mime, - body.len() as u64, - Some(&meta), - uploaded_at, - )), + .await + { + Ok(meta) => meta, Err(e) => { tracing::warn!(sha256 = %sha256, error = %e, "metadata generation failed; orphan blob left for GC"); - Err(e) + return Err(e); } + }; + + // The moderation record precedes the sidecar publish gate. If this write + // fails, the blob and any thumbnail remain orphaned but the media cannot be + // served. Conversely, record existence still implies those objects exist. + if let Some(attribution) = &attribution { + record_upload_event( + storage, + ctx, + &auth_event.pubkey, + attribution, + UploadEventFacts { + sha256: &sha256, + ext: &ext, + mime: &mime, + size: body.len() as u64, + uploaded_at, + }, + ) + .await?; } + storage.put_sidecar(ctx, &sha256, &meta).await?; + + Ok(build_descriptor( + config, + &sha256, + &ext, + &mime, + body.len() as u64, + Some(&meta), + uploaded_at, + )) } /// Inputs handed to a buffered-upload metadata builder, after the shared @@ -143,19 +209,23 @@ pub async fn process_upload( ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, + attribution: Option, ) -> Result { process_buffered_upload( - storage, - config, - ctx, - auth_event, - body, + BufferedUploadInput { + storage, + config, + ctx, + auth_event, + body, + attribution, + }, |bytes, cfg| { let mime = validate_content(bytes, cfg)?; let ext = mime_to_ext(&mime).to_string(); Ok((mime, ext)) }, - |input| async move { generate_and_store_metadata(storage, config, ctx, input).await }, + |input| async move { prepare_image_metadata(storage, config, input).await }, ) .await } @@ -176,13 +246,17 @@ pub async fn process_file_upload( ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, + attribution: Option, ) -> Result { process_buffered_upload( - storage, - config, - ctx, - auth_event, - body, + BufferedUploadInput { + storage, + config, + ctx, + auth_event, + body, + attribution, + }, |bytes, cfg| validate_file_content(bytes, cfg), |input| async move { // Minimal sidecar — no thumbnail/dim/blurhash/duration for generic files. @@ -196,7 +270,6 @@ pub async fn process_file_upload( uploaded_at: input.uploaded_at, duration_secs: None, }; - storage.put_sidecar(ctx, &input.sha256, &meta).await?; Ok(meta) }, ) @@ -221,6 +294,7 @@ pub async fn process_video_upload( auth_event: &nostr::Event, body_stream: impl futures_core::Stream> + Send + 'static, content_length: Option, + attribution: Option, ) -> Result { // --- 1. Stream body to temp file, compute SHA-256 incrementally --- let tmp = tempfile::NamedTempFile::new().map_err(|e| MediaError::Io(e.to_string()))?; @@ -357,6 +431,24 @@ pub async fn process_video_upload( let blob_exists = storage.head(&key).await?; if sidecar_exists && blob_exists { let meta = storage.get_sidecar(ctx, &sha256_hex).await?; + // Re-upload of known bytes: still a distinct upload event — see the + // buffered path's short-circuit for the rationale. + if let Some(attribution) = &attribution { + record_upload_event( + storage, + ctx, + &auth_event.pubkey, + attribution, + UploadEventFacts { + sha256: &sha256_hex, + ext, + mime: &mime, + size: file_size, + uploaded_at: chrono::Utc::now().timestamp(), + }, + ) + .await?; + } return Ok(build_descriptor( config, &sha256_hex, @@ -374,7 +466,7 @@ pub async fn process_video_upload( storage.put_file(&key, &tmp_path, &mime).await?; drop(tmp); // Free temp file disk space immediately after S3 upload. - // --- 7. Write sidecar (no thumbnail for video — desktop handles that) --- + // --- 7. Build metadata (no thumbnail for video — desktop handles that) --- let meta = BlobMeta { dim: format!("{}x{}", video_meta.width, video_meta.height), blurhash: String::new(), @@ -385,6 +477,24 @@ pub async fn process_video_upload( uploaded_at, duration_secs: Some(video_meta.duration_secs), }; + + // Record before publishing the sidecar serve gate. See the buffered path. + if let Some(attribution) = &attribution { + record_upload_event( + storage, + ctx, + &auth_event.pubkey, + attribution, + UploadEventFacts { + sha256: &sha256_hex, + ext, + mime: &mime, + size: file_size, + uploaded_at, + }, + ) + .await?; + } storage.put_sidecar(ctx, &sha256_hex, &meta).await?; Ok(build_descriptor( @@ -398,12 +508,11 @@ pub async fn process_video_upload( )) } -/// Generate thumbnail, blurhash, and sidecar metadata, then store them. +/// Generate thumbnail and metadata without publishing the sidecar serve gate. /// Returns the completed [`BlobMeta`] on success. -async fn generate_and_store_metadata( +async fn prepare_image_metadata( storage: &MediaStorage, config: &MediaConfig, - ctx: &TenantContext, input: MetadataInput, ) -> Result { let body_ref = input.body.clone(); @@ -424,7 +533,6 @@ async fn generate_and_store_metadata( storage.put(&thumb_key, tb, "image/jpeg").await?; } - storage.put_sidecar(ctx, &input.sha256, &meta).await?; Ok(meta) } @@ -467,6 +575,9 @@ mod tests { max_video_bytes: 524_288_000, max_file_bytes: 104_857_600, public_base_url: "https://media.example.com".to_string(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, } } diff --git a/crates/buzz-media/src/upload_record.rs b/crates/buzz-media/src/upload_record.rs new file mode 100644 index 0000000000..42f57fbd26 --- /dev/null +++ b/crates/buzz-media/src/upload_record.rs @@ -0,0 +1,419 @@ +//! Per-upload-event records — the moderation side channel. +//! +//! Content-addressed storage keys facts about *bytes*; moderation and legal +//! reporting (e.g. NCMEC CyberTipline) need facts about *upload events*: who +//! uploaded, when, from which network address. This module writes one small +//! append-only JSON record per **accepted** upload — including the idempotent +//! re-upload short-circuit, which does no blob PUT and is therefore invisible +//! to any blob-creation-driven pipeline. +//! +//! Key layout (alongside the existing `_meta/` sidecar convention): +//! +//! ```text +//! _uploads/{community}/{sha256}/{event_id}.json +//! ``` +//! +//! `event_id` is a ULID — unique and time-sortable, one record per accepted +//! upload event. Records are unreachable through the media serve path by +//! construction (`validate_media_path` requires a bare 64-hex first segment), +//! and the bucket is only accessible via the relay's IAM role. +//! +//! The whole feature is **off by default** and gated behind +//! `BUZZ_MEDIA_UPLOAD_RECORDS`. IP collection is a second, independent opt-in +//! (`BUZZ_MEDIA_UPLOAD_IP_HEADER`) and is *fail-empty*: a missing, malformed, +//! or non-public address records nothing — a wrong IP is worse than no IP, +//! so absent is always preferable. The IP goes only into this +//! record — never blob metadata, never the upload response, never the +//! hash-chained audit log. +//! +//! ## Consumer contract (buzz-moderation) +//! +//! The moderation pipeline triggers on `ObjectCreated` events under the +//! `_uploads/` prefix and parses this record instead of HEADing blobs: +//! +//! - For fresh uploads, the record is written after the blob and derived +//! artifacts but before the sidecar serve gate. Record existence therefore +//! implies the scan inputs are readable, while record failure cannot leave +//! unscanned media publicly servable. +//! - `ext`, `mime_type`, and `size` are always present so the consumer can +//! derive the blob key (`{sha256}.{ext}`) and scan eligibility without +//! extra round-trips. +//! - `uploader_name`, `ip`, and `port` are omitted (never `null`) when +//! unknown or when collection is disabled. +//! - Consumers must tolerate unknown fields; `version` bumps only on +//! breaking changes to existing fields. + +use std::net::IpAddr; + +use buzz_core::tenant::TenantContext; +use serde::{Deserialize, Serialize}; + +/// Current record schema version. Additive fields do not bump this. +pub const UPLOAD_RECORD_VERSION: u32 = 1; + +/// One accepted upload event. See module docs for the consumer contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadRecord { + /// Schema version ([`UPLOAD_RECORD_VERSION`]). + pub version: u32, + /// ULID — unique per accepted upload, time-sortable. Also the key suffix. + pub event_id: String, + /// Content hash of the uploaded bytes (64 lowercase hex chars). + pub sha256: String, + /// Canonical extension — consumers derive the blob key `{sha256}.{ext}`. + pub ext: String, + /// Sniffed MIME type of the uploaded bytes. + pub mime_type: String, + /// Size of the uploaded bytes. + pub size: u64, + /// Unix seconds when the relay accepted *this* upload event. On an + /// idempotent re-upload this is the re-upload time, not the original + /// blob's `uploaded_at`. + pub uploaded_at: i64, + /// Server-resolved community id (UUID). Never client-supplied. + pub community_id: String, + /// Server-resolved tenant host the upload was bound to. + pub community_host: String, + /// Authenticated uploader pubkey (64 lowercase hex chars). + pub uploader_id: String, + /// Same pubkey, bech32 `npub` encoding. + pub uploader_npub: String, + /// Uploader's configured display name at upload time. Best-effort label; + /// `uploader_id` is authoritative. Omitted when unset. + #[serde(skip_serializing_if = "Option::is_none")] + pub uploader_name: Option, + /// Uploader's public IP as reported by the configured edge header. + /// Present only when `BUZZ_MEDIA_UPLOAD_IP_HEADER` is set AND the header + /// held a valid public address (fail-empty). Omitted, never `null`. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip: Option, + /// Uploader's source port as reported by the configured edge header. + /// Standard edge headers don't carry the client port, so this is + /// best-effort and usually absent. Only recorded alongside `ip`. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +/// Network address facts extracted from trusted edge headers by the HTTP +/// handler, already validated (fail-empty). `Default` is "nothing collected". +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct UploadNetworkInfo { + /// Validated public IP of the uploader, or `None`. + pub ip: Option, + /// Source port of the uploader, or `None`. Ignored when `ip` is `None`. + pub port: Option, +} + +/// Per-event upload attribution passed into the upload pipeline by the +/// handler. Only consulted when upload records are enabled. +#[derive(Debug, Clone, Default)] +pub struct UploadAttribution { + /// Uploader's configured display name, if known (sanitized, bounded). + pub uploader_name: Option, + /// Validated network facts from trusted edge headers. + pub net: UploadNetworkInfo, +} + +/// Facts about the accepted upload, computed by the upload pipeline. +#[derive(Debug, Clone, Copy)] +pub struct UploadEventFacts<'a> { + /// Content hash (64 lowercase hex chars). + pub sha256: &'a str, + /// Canonical extension. + pub ext: &'a str, + /// Sniffed MIME type. + pub mime: &'a str, + /// Uploaded byte size. + pub size: u64, + /// Unix seconds this upload event was accepted. + pub uploaded_at: i64, +} + +/// Build and store the per-event record for one accepted upload. +/// +/// Called after blob and derived-artifact durability but before the sidecar +/// publish gate on fresh uploads; called on the existing published state for +/// idempotent re-uploads. A write failure propagates and fails the upload. The +/// record's `ObjectCreated` event is the moderation pipeline's only scan +/// trigger, so no newly published media may exist without a record. +pub async fn record_upload_event( + storage: &crate::storage::MediaStorage, + ctx: &TenantContext, + uploader: &nostr::PublicKey, + attribution: &UploadAttribution, + facts: UploadEventFacts<'_>, +) -> Result<(), crate::error::MediaError> { + use nostr::ToBech32; + + let event_id = ulid::Ulid::new().to_string(); + // Ports are only meaningful next to the address they were observed with. + let ip = attribution.net.ip; + let port = ip.and(attribution.net.port); + let record = UploadRecord { + version: UPLOAD_RECORD_VERSION, + event_id: event_id.clone(), + sha256: facts.sha256.to_string(), + ext: facts.ext.to_string(), + mime_type: facts.mime.to_string(), + size: facts.size, + uploaded_at: facts.uploaded_at, + community_id: ctx.community().to_string(), + community_host: ctx.host().to_string(), + uploader_id: uploader.to_hex(), + uploader_npub: uploader.to_bech32().map_err(|e| { + // Unreachable for a valid pubkey; surfaced rather than unwrapped. + crate::error::MediaError::StorageError(format!("npub encoding failed: {e}")) + })?, + uploader_name: attribution.uploader_name.clone(), + ip: ip.map(|addr| addr.to_string()), + port, + }; + let key = upload_record_key(ctx, facts.sha256, &event_id); + let json = serde_json::to_vec(&record)?; + storage.put(&key, &json, "application/json").await +} + +/// Build the per-event record key: +/// `_uploads/{community}/{sha256}/{event_id}.json`. +/// +/// `ctx` must be the server-resolved request tenant — same fence as the +/// `_meta/` sidecar key (see [`crate::storage::MediaStorage::sidecar_key`]). +pub fn upload_record_key(ctx: &TenantContext, sha256: &str, event_id: &str) -> String { + format!("_uploads/{}/{sha256}/{event_id}.json", ctx.community()) +} + +/// Parse an IP header value, accepting only public addresses (fail-empty). +/// +/// Returns `None` — record nothing — for anything that is not a single, +/// syntactically valid, public IP: garbage, comma lists, private ranges, +/// loopback, link-local, CGNAT, multicast, documentation, ULA, etc. Never +/// guesses and never falls back to the socket address. +pub fn parse_public_ip(raw: &str) -> Option { + let ip: IpAddr = raw.trim().parse().ok()?; + is_public_ip(&ip).then_some(ip) +} + +/// Parse a port header value: a single decimal u16, non-zero. +pub fn parse_port(raw: &str) -> Option { + raw.trim().parse::().ok().filter(|&p| p != 0) +} + +/// Conservative "is this a public, routable address" check. +/// +/// `IpAddr::is_global` is unstable, so this enumerates the reserved ranges +/// explicitly. Anything not recognizably public is rejected — the cost of a +/// false negative is an absent field; the cost of a false positive is a wrong +/// address in a federal report. +fn is_public_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let octets = v4.octets(); + !(v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_documentation() + || v4.is_multicast() + || v4.is_unspecified() + // This network 0.0.0.0/8 (RFC 1122) + || octets[0] == 0 + // CGNAT 100.64.0.0/10 (RFC 6598) + || (octets[0] == 100 && (octets[1] & 0b1100_0000) == 64) + // Reserved 240.0.0.0/4 (RFC 1112) — is_broadcast covers .255 only + || octets[0] >= 240 + // Benchmarking 198.18.0.0/15 (RFC 2544) + || (octets[0] == 198 && (octets[1] & 0xFE) == 18) + // IETF protocol assignments 192.0.0.0/24 (RFC 6890) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)) + } + IpAddr::V6(v6) => { + let seg = v6.segments(); + !(v6.is_loopback() + || v6.is_multicast() + || v6.is_unspecified() + // Unique local fc00::/7 (RFC 4193) + || (seg[0] & 0xFE00) == 0xFC00 + // Link-local fe80::/10 (RFC 4291) + || (seg[0] & 0xFFC0) == 0xFE80 + // Discard-only 100::/64 (RFC 6666) + || (seg[0] == 0x0100 && seg[1..4] == [0, 0, 0]) + // Teredo 2001::/32 (RFC 4380) + || (seg[0] == 0x2001 && seg[1] == 0) + // Benchmarking 2001:2::/48 (RFC 5180) + || (seg[0] == 0x2001 && seg[1] == 2 && seg[2] == 0) + // Documentation 2001:db8::/32 (RFC 3849) + || (seg[0] == 0x2001 && seg[1] == 0x0DB8) + // 6to4 2002::/16 (RFC 3056) + || seg[0] == 0x2002 + // Documentation 3fff::/20 (RFC 9637) + || (seg[0] & 0xFFF0) == 0x3FF0 + // IPv4-mapped ::ffff:0:0/96 — the embedded v4 was already + // rejected above if it arrived as dotted quad; reject the + // mapped form outright rather than re-deriving it. + || v6.to_ipv4_mapped().is_some()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::tenant::CommunityId; + + fn tenant() -> TenantContext { + TenantContext::resolved( + CommunityId::from_uuid(uuid::Uuid::from_u128(7)), + "chat.example.com", + ) + } + + #[test] + fn record_key_matches_spec_layout() { + let ctx = tenant(); + let sha = "a".repeat(64); + let key = upload_record_key(&ctx, &sha, "01J9W3ULIDULIDULIDULIDULID"); + assert_eq!( + key, + format!( + "_uploads/{}/{sha}/01J9W3ULIDULIDULIDULIDULID.json", + ctx.community() + ) + ); + } + + #[test] + fn record_serializes_full_shape() { + let record = UploadRecord { + version: UPLOAD_RECORD_VERSION, + event_id: "01J9W3TEST".into(), + sha256: "b".repeat(64), + ext: "png".into(), + mime_type: "image/png".into(), + size: 12345, + uploaded_at: 1_783_358_352, + community_id: uuid::Uuid::from_u128(7).to_string(), + community_host: "chat.example.com".into(), + uploader_id: "c".repeat(64), + uploader_npub: "npub1example".into(), + uploader_name: Some("alice".into()), + ip: Some("203.0.113.7".into()), + port: Some(51234), + }; + let json = serde_json::to_value(&record).unwrap(); + assert_eq!(json["version"], 1); + assert_eq!(json["ext"], "png"); + assert_eq!(json["mime_type"], "image/png"); + assert_eq!(json["size"], 12345); + assert_eq!(json["ip"], "203.0.113.7"); + assert_eq!(json["port"], 51234); + assert_eq!(json["uploader_name"], "alice"); + } + + #[test] + fn record_omits_absent_optionals_entirely() { + let record = UploadRecord { + version: UPLOAD_RECORD_VERSION, + event_id: "01J9W3TEST".into(), + sha256: "b".repeat(64), + ext: "mp4".into(), + mime_type: "video/mp4".into(), + size: 1, + uploaded_at: 0, + community_id: "cid".into(), + community_host: "h".into(), + uploader_id: "c".repeat(64), + uploader_npub: "npub1example".into(), + uploader_name: None, + ip: None, + port: None, + }; + let json = serde_json::to_value(&record).unwrap(); + // Omitted, not null — the consumer contract. + assert!(json.get("uploader_name").is_none()); + assert!(json.get("ip").is_none()); + assert!(json.get("port").is_none()); + } + + #[test] + fn record_deserialization_tolerates_unknown_fields() { + // Forward-compat: additive relay fields must not break older parsers + // of the same version (mirrors the consumer's requirement). + let json = r#"{ + "version": 1, "event_id": "01J", "sha256": "ab", "ext": "png", + "mime_type": "image/png", "size": 1, "uploaded_at": 2, + "community_id": "c", "community_host": "h", + "uploader_id": "u", "uploader_npub": "n", + "some_future_field": {"nested": true} + }"#; + let record: UploadRecord = serde_json::from_str(json).unwrap(); + assert_eq!(record.version, 1); + assert_eq!(record.ip, None); + } + + #[test] + fn public_ips_accepted() { + for raw in [ + "8.8.8.8", + "1.1.1.1", + " 93.184.216.34 ", // trims whitespace + "2600:1f18::1", + "2a00:1450:4009:81f::200e", + ] { + assert!(parse_public_ip(raw).is_some(), "should accept {raw}"); + } + } + + #[test] + fn non_public_ips_fail_empty() { + for raw in [ + "", + "not-an-ip", + "10.0.0.1", // private + "172.16.0.1", // private + "192.168.1.1", // private + "127.0.0.1", // loopback + "169.254.1.1", // link-local + "100.64.0.1", // CGNAT + "100.127.255.255", // CGNAT upper edge + "0.0.0.0", // unspecified + "0.1.2.3", // this network 0.0.0.0/8 + "255.255.255.255", // broadcast + "224.0.0.1", // multicast + "240.0.0.1", // reserved + "198.18.0.1", // benchmarking + "192.0.0.1", // IETF assignments + "203.0.113.7", // documentation (TEST-NET-3) + "198.51.100.1", // documentation (TEST-NET-2) + "192.0.2.1", // documentation (TEST-NET-1) + "::1", // v6 loopback + "::", // v6 unspecified + "fe80::1", // v6 link-local + "fc00::1", // v6 ULA + "fd12:3456::1", // v6 ULA + "ff02::1", // v6 multicast + "100::1", // v6 discard-only + "2001::1", // Teredo + "2001:2::1", // v6 benchmarking + "2001:db8::1", // v6 documentation + "2002::1", // 6to4 + "3fff::1", // v6 documentation + "::ffff:8.8.8.8", // v4-mapped — reject the mapped form + "8.8.8.8, 1.1.1.1", // comma list — not a single IP + "8.8.8.8:443", // ip:port — not a bare IP + ] { + assert!(parse_public_ip(raw).is_none(), "should reject {raw:?}"); + } + } + + #[test] + fn port_parses_single_nonzero_u16() { + assert_eq!(parse_port("51234"), Some(51234)); + assert_eq!(parse_port(" 443 "), Some(443)); + assert_eq!(parse_port("0"), None); + assert_eq!(parse_port("65536"), None); + assert_eq!(parse_port("-1"), None); + assert_eq!(parse_port("443, 444"), None); + assert_eq!(parse_port("abc"), None); + assert_eq!(parse_port(""), None); + } +} diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index 328516bb12..74b4ab0d9b 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -422,6 +422,9 @@ mod tests { max_video_bytes: 524_288_000, max_file_bytes: 104_857_600, public_base_url: String::new(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, } } diff --git a/crates/buzz-media/tests/static_creds_minio.rs b/crates/buzz-media/tests/static_creds_minio.rs index 38dc6618cc..d7591238c2 100644 --- a/crates/buzz-media/tests/static_creds_minio.rs +++ b/crates/buzz-media/tests/static_creds_minio.rs @@ -35,6 +35,9 @@ fn minio_config() -> MediaConfig { max_video_bytes: 524_288_000, max_file_bytes: 104_857_600, public_base_url: "http://localhost:3000/media".to_string(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, } } diff --git a/crates/buzz-pair-relay/src/main.rs b/crates/buzz-pair-relay/src/main.rs index 1e81756e3b..4e10d0940f 100644 --- a/crates/buzz-pair-relay/src/main.rs +++ b/crates/buzz-pair-relay/src/main.rs @@ -6,7 +6,15 @@ use tokio::net::TcpListener; #[tokio::main] async fn main() { - let addr: SocketAddr = ([127, 0, 0, 1], 5000).into(); + let addr_raw = + std::env::var("BUZZ_PAIR_RELAY_BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:5000".to_string()); + let addr: SocketAddr = match addr_raw.parse() { + Ok(addr) => addr, + Err(e) => { + eprintln!("fatal: invalid BUZZ_PAIR_RELAY_BIND_ADDR {addr_raw:?}: {e}"); + std::process::exit(1); + } + }; let listener = match TcpListener::bind(addr).await { Ok(l) => l, Err(e) => { diff --git a/crates/buzz-persona/src/persona.rs b/crates/buzz-persona/src/persona.rs index 487582a999..b7a49d46b4 100644 --- a/crates/buzz-persona/src/persona.rs +++ b/crates/buzz-persona/src/persona.rs @@ -142,7 +142,7 @@ pub struct PersonaConfig { #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, - /// Preferred ACP runtime ID (e.g., 'goose', 'claude'). Maps to PersonaRecord.runtime during + /// Preferred ACP runtime ID (e.g., 'goose', 'claude'). Maps to AgentDefinition.runtime during /// pack import. #[serde(skip_serializing_if = "Option::is_none")] pub runtime: Option, diff --git a/crates/buzz-persona/src/resolve.rs b/crates/buzz-persona/src/resolve.rs index 0a7c9afa8a..614486661a 100644 --- a/crates/buzz-persona/src/resolve.rs +++ b/crates/buzz-persona/src/resolve.rs @@ -28,8 +28,10 @@ pub struct ResolvedPersona { pub avatar: Option, pub version: String, - // → Config.system_prompt (persona body + pack_instructions) + // → Config.system_prompt (persona body only) pub system_prompt: String, + /// Pack-owned instructions kept separate for the team model migration. + pub pack_instructions: Option, // → Config.model (plain model ID, post-split) pub model: Option, @@ -37,7 +39,7 @@ pub struct ResolvedPersona { /// from 'databricks:model-id'). Flows into harness-specific env vars (GOOSE_PROVIDER) only. pub llm_provider: Option, /// Preferred ACP runtime ID from the persona config (e.g., 'goose', 'claude'). Maps to - /// PersonaRecord.runtime during pack import. + /// AgentDefinition.runtime during pack import. pub runtime: Option, pub temperature: Option, pub max_context_tokens: Option, @@ -195,7 +197,11 @@ fn resolve_one_persona( pack_instructions: Option<&str>, shared_mcp: Option<&serde_json::Value>, ) -> ResolvedPersona { - let system_prompt = compose_prompt(&lp.prompt, pack_instructions); + let system_prompt = lp.prompt.clone(); + let pack_instructions = pack_instructions + .map(str::trim) + .filter(|instructions| !instructions.is_empty()) + .map(str::to_string); // Split "provider:model-id" into separate fields (V3 contract). let (llm_provider, model) = match lp.model.as_deref() { @@ -228,6 +234,7 @@ fn resolve_one_persona( avatar: lp.avatar.clone(), version, system_prompt, + pack_instructions, model, llm_provider, runtime: lp.runtime.clone(), @@ -244,16 +251,6 @@ fn resolve_one_persona( } } -/// Compose the effective system prompt: persona body + pack instructions. -fn compose_prompt(persona_prompt: &str, pack_instructions: Option<&str>) -> String { - match pack_instructions { - Some(instructions) if !instructions.trim().is_empty() => { - format!("{persona_prompt}\n\n---\n# Team Instructions\n{instructions}") - } - _ => persona_prompt.to_owned(), - } -} - /// Convert `TriggersData` to `ResolvedTriggers`. fn resolve_triggers(rt: Option<&TriggersData>) -> ResolvedTriggers { match rt { @@ -407,26 +404,6 @@ mod tests { use crate::merge::{HooksData, TriggersData}; - #[test] - fn compose_prompt_body_only() { - let result = compose_prompt("You are a bot.", None); - assert_eq!(result, "You are a bot."); - } - - #[test] - fn compose_prompt_with_instructions() { - let result = compose_prompt("You are a bot.", Some("Follow the rules.")); - assert!(result.starts_with("You are a bot.")); - assert!(result.contains("# Team Instructions")); - assert!(result.contains("Follow the rules.")); - } - - #[test] - fn compose_prompt_empty_instructions_ignored() { - let result = compose_prompt("You are a bot.", Some(" ")); - assert_eq!(result, "You are a bot."); - } - #[test] fn triggers_from_triggers_data() { let data = TriggersData { @@ -708,10 +685,9 @@ mod tests { let pack = resolve_pack(dir).unwrap(); let p = &pack.personas[0]; - // Prompt composed with instructions - assert!(p.system_prompt.contains("You are Bot.")); - assert!(p.system_prompt.contains("# Team Instructions")); - assert!(p.system_prompt.contains("Always be helpful.")); + // Persona body kept separate from pack instructions. + assert_eq!(p.system_prompt, "You are Bot.\n"); + assert_eq!(p.pack_instructions.as_deref(), Some("Always be helpful.")); // Model split into separate fields (V3 contract) assert_eq!(p.model.as_deref(), Some("claude-sonnet-4-20250514")); diff --git a/crates/buzz-persona/tests/integration.rs b/crates/buzz-persona/tests/integration.rs index 76706e66e2..0b5ffc3b73 100644 --- a/crates/buzz-persona/tests/integration.rs +++ b/crates/buzz-persona/tests/integration.rs @@ -420,18 +420,20 @@ fn resolve_full_pipeline() { assert_eq!(lep.llm_provider.as_deref(), Some("anthropic")); assert_eq!(lep.model.as_deref(), Some("claude-sonnet-4-20250514")); - // System prompt composed: persona body + pack instructions + // System prompt is persona body only; pack instructions stay separate. assert!( pip.system_prompt.contains("You are Pip"), "pip prompt should contain persona body" ); assert!( - pip.system_prompt.contains("Be helpful"), - "pip prompt should contain pack instructions" + !pip.system_prompt.contains("Be helpful"), + "pip prompt should not contain pack instructions" ); assert!( - pip.system_prompt.contains("Team Instructions"), - "pip prompt should contain instructions header" + pip.pack_instructions + .as_deref() + .is_some_and(|instructions| instructions.contains("Be helpful")), + "pip.pack_instructions should carry the pack instructions" ); // Temperature inherited from defaults diff --git a/crates/buzz-pubsub/src/conn_control.rs b/crates/buzz-pubsub/src/conn_control.rs new file mode 100644 index 0000000000..bc177cff13 --- /dev/null +++ b/crates/buzz-pubsub/src/conn_control.rs @@ -0,0 +1,229 @@ +//! Cross-pod connection-control commands over Redis pub/sub. +//! +//! Under horizontal scaling a member's live connections may land on any pod, +//! so a moderation action taken on one pod (a ban) must reach the pod holding +//! the victim's socket. This module carries connection-control intents — today +//! only "disconnect this pubkey" — to every pod, which each apply locally +//! against their own [`crate::ConnectionManager`]. +//! +//! This is deliberately a **separate** channel from `cache_invalidation`: a +//! cache-key drop is a pure, idempotent hint (the DB is re-read on the next +//! access), whereas a disconnect is an imperative, non-idempotent action on a +//! live socket. Folding it into the cache-invalidation enum would break that +//! module's stated invariant ("a pure cache-key drop, never an evict payload"). +//! The DB ban row remains the durable backstop: even if a disconnect message is +//! dropped, the next auth attempt is refused at the auth seam. + +use buzz_core::{CommunityId, TenantContext}; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; +use uuid::Uuid; + +use crate::topic::BUZZ_PREFIX; + +/// Tenant-local Redis pub/sub channel suffix for connection-control messages. +pub const CONN_CONTROL_SUFFIX: &str = "conn-control"; + +/// Pattern the subscriber uses to receive connection-control messages for every +/// community this pod may hold connections for. +pub const CONN_CONTROL_PATTERN: &str = "buzz:*:conn-control"; + +/// Redis pub/sub channel for connection-control messages under `ctx`. +pub fn conn_control_channel(ctx: &TenantContext) -> String { + format!("{BUZZ_PREFIX}:{}:{CONN_CONTROL_SUFFIX}", ctx.community()) +} + +/// Parse a connection-control Redis channel into its scoped community id. +pub fn parse_conn_control_channel(channel: &str) -> Option { + let mut parts = channel.split(':'); + if parts.next()? != BUZZ_PREFIX { + return None; + } + let community_id = Uuid::parse_str(parts.next()?).ok()?; + if parts.next()? != CONN_CONTROL_SUFFIX { + return None; + } + if parts.next().is_some() { + return None; + } + Some(CommunityId::from_uuid(community_id)) +} + +/// A connection-control command to apply on every pod. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "op")] +pub enum ConnControl { + /// Disconnect every live socket bound to the carrying community. + DisconnectCommunity, + /// Disconnect every live connection authenticated as `pubkey` in the + /// carrying community — live ban enforcement. `pubkey` is 32 raw bytes. + /// `event_id` and `reason` reproduce the same NIP-01 `OK` frame the origin + /// pod sent, so a member disconnected on any pod learns why. + DisconnectPubkey { + /// Banned member's pubkey bytes. + pubkey: Vec, + /// Id echoed in the closing `OK` frame (the ban event's id on origin). + event_id: String, + /// Human-readable close reason for the `OK` frame. + reason: String, + }, +} + +/// A connection-control command received from a community-scoped Redis channel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScopedConnControl { + /// Community whose connections the command applies to. + pub community_id: CommunityId, + /// The tenant-local connection-control command. + pub command: ConnControl, +} + +/// Initial reconnect backoff (1 second). +const BACKOFF_INITIAL_SECS: u64 = 1; +/// Maximum reconnect backoff (30 seconds). +const BACKOFF_MAX_SECS: u64 = 30; + +/// Subscribes to `buzz:*:conn-control` and forwards scoped commands to the +/// broadcast. Mirrors [`crate::cache_invalidation::run_cache_invalidation_subscriber`]: +/// a reconnect loop with exponential backoff. Never returns. +pub async fn run_conn_control_subscriber( + redis_url: String, + broadcast_tx: broadcast::Sender, +) { + let mut backoff_secs = BACKOFF_INITIAL_SECS; + + loop { + match connect_and_subscribe(&redis_url, &broadcast_tx).await { + Ok(()) => { + backoff_secs = BACKOFF_INITIAL_SECS; + tracing::warn!( + "Redis conn-control stream ended (clean disconnect) — reconnecting in {backoff_secs}s" + ); + } + Err(e) => { + tracing::error!("Redis conn-control error: {e} — reconnecting in {backoff_secs}s"); + } + } + + tokio::time::sleep(tokio::time::Duration::from_secs(backoff_secs)).await; + backoff_secs = (backoff_secs * 2).min(BACKOFF_MAX_SECS); + + tracing::info!("Attempting to reconnect to Redis conn-control..."); + } +} + +async fn connect_and_subscribe( + redis_url: &str, + broadcast_tx: &broadcast::Sender, +) -> Result<(), redis::RedisError> { + let client = redis::Client::open(redis_url)?; + let mut conn = client.get_async_pubsub().await?; + + conn.psubscribe(CONN_CONTROL_PATTERN).await?; + + tracing::info!("Redis conn-control subscriber connected — listening on {CONN_CONTROL_PATTERN}"); + + let mut stream = conn.on_message(); + while let Some(msg) = stream.next().await { + let channel = msg.get_channel_name(); + let Some(community_id) = parse_conn_control_channel(channel) else { + tracing::warn!("Received conn-control message on unexpected channel: {channel}"); + continue; + }; + + let payload: String = match msg.get_payload() { + Ok(p) => p, + Err(e) => { + tracing::warn!("Failed to get conn-control payload: {e}"); + continue; + } + }; + + let command: ConnControl = match serde_json::from_str(&payload) { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to deserialize conn-control message: {e}"); + continue; + } + }; + + let scoped = ScopedConnControl { + community_id, + command, + }; + + if broadcast_tx.send(scoped).is_err() { + tracing::trace!("No conn-control receivers — message dropped"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx(id: u128, host: &str) -> TenantContext { + TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host) + } + + #[test] + fn conn_control_channel_is_community_scoped() { + let a = ctx(0xaaaa, "a.example"); + let b = ctx(0xbbbb, "b.example"); + assert_eq!( + conn_control_channel(&a), + format!("buzz:{}:conn-control", a.community()) + ); + assert_ne!(conn_control_channel(&a), conn_control_channel(&b)); + } + + #[test] + fn parse_round_trips_the_community() { + let a = ctx(0x1234, "a.example"); + let channel = conn_control_channel(&a); + assert_eq!(parse_conn_control_channel(&channel), Some(a.community())); + } + + #[test] + fn parse_rejects_foreign_channels() { + assert_eq!( + parse_conn_control_channel("buzz:not-a-uuid:conn-control"), + None + ); + assert_eq!(parse_conn_control_channel("buzz:*:cache-invalidate"), None); + let a = ctx(0x1234, "a.example"); + let extended = format!("{}:extra", conn_control_channel(&a)); + assert_eq!(parse_conn_control_channel(&extended), None); + } + + #[test] + fn disconnect_community_command_serde_round_trips() { + let cmd = ConnControl::DisconnectCommunity; + let json = serde_json::to_string(&cmd).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), cmd); + } + + #[test] + fn unknown_command_is_rejected_without_affecting_later_messages() { + assert!(serde_json::from_str::(r#"{"op":"FutureCommand"}"#).is_err()); + let known = serde_json::to_string(&ConnControl::DisconnectCommunity).unwrap(); + assert_eq!( + serde_json::from_str::(&known).unwrap(), + ConnControl::DisconnectCommunity + ); + } + + #[test] + fn disconnect_command_serde_round_trips() { + let cmd = ConnControl::DisconnectPubkey { + pubkey: vec![7u8; 32], + event_id: "abc123".to_string(), + reason: "blocked: you are banned from this community".to_string(), + }; + let json = serde_json::to_string(&cmd).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), cmd); + } +} diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 550f32d65e..eae8c5ef9e 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -23,6 +23,8 @@ /// Cross-pod cache-key invalidation over Redis pub/sub. pub mod cache_invalidation; +/// Cross-pod connection-control commands over Redis pub/sub. +pub mod conn_control; /// Error types for pub/sub operations. pub mod error; /// Redis-backed NIP-98 replay seen-set. @@ -52,6 +54,7 @@ use tokio::sync::{broadcast, mpsc, Mutex}; use crate::cache_invalidation::{ cache_invalidation_channel, CacheInvalidation, ScopedCacheInvalidation, }; +use crate::conn_control::{conn_control_channel, ConnControl, ScopedConnControl}; pub use crate::topic::{channel_key, global_key, EventTopic, EventTopicKey}; /// A Nostr event received on a scoped Redis event topic, broadcast to local subscribers. @@ -106,6 +109,7 @@ pub struct PubSubManager { subscription_rx: Mutex>>, broadcast_tx: broadcast::Sender, cache_invalidation_tx: broadcast::Sender, + conn_control_tx: broadcast::Sender, } impl PubSubManager { @@ -121,6 +125,7 @@ impl PubSubManager { ) -> Result { let (broadcast_tx, _) = broadcast::channel(4096); let (cache_invalidation_tx, _) = broadcast::channel(4096); + let (conn_control_tx, _) = broadcast::channel(4096); let (subscription_tx, subscription_rx) = mpsc::channel(4096); Ok(Self { @@ -132,6 +137,7 @@ impl PubSubManager { subscription_rx: Mutex::new(Some(subscription_rx)), broadcast_tx, cache_invalidation_tx, + conn_control_tx, }) } @@ -164,6 +170,16 @@ impl PubSubManager { .await; } + /// Starts the connection-control subscriber loop with automatic + /// reconnection. Runs forever — spawn this in a background task. + pub async fn run_conn_control_subscriber(self: Arc) { + conn_control::run_conn_control_subscriber( + self.redis_url.clone(), + self.conn_control_tx.clone(), + ) + .await; + } + /// Returns a new broadcast receiver for locally-published channel events. pub fn subscribe_local(&self) -> broadcast::Receiver { self.broadcast_tx.subscribe() @@ -244,6 +260,11 @@ impl PubSubManager { self.cache_invalidation_tx.subscribe() } + /// Returns a new broadcast receiver for cross-pod connection-control commands. + pub fn subscribe_conn_control(&self) -> broadcast::Receiver { + self.conn_control_tx.subscribe() + } + /// Publish a cache-key drop to all pods. Fire-and-forget at the call site: /// the local cache is already dropped synchronously; this carries the same /// drop cross-pod. A dropped publish is backstopped by the REQ denial-path @@ -263,6 +284,26 @@ impl PubSubManager { Ok(subscriber_count) } + /// Publish a connection-control command to all pods. Used for live ban + /// enforcement: the banning pod disconnects any local sockets synchronously + /// and calls this to reach the banned member's sockets on other pods. The DB + /// ban row is the durable backstop, so a dropped publish still refuses the + /// next auth attempt; callers may spawn this without awaiting delivery. + pub async fn publish_conn_control( + &self, + ctx: &TenantContext, + command: &ConnControl, + ) -> Result { + let mut conn = self.pool.get().await?; + let payload = serde_json::to_string(command)?; + let subscriber_count: i64 = redis::cmd("PUBLISH") + .arg(conn_control_channel(ctx)) + .arg(&payload) + .query_async(&mut conn) + .await?; + Ok(subscriber_count) + } + /// Publish an event to the Redis channel. Returns subscriber count. /// /// Routing note (NIP-ER author-private reminders): events are keyed by diff --git a/crates/buzz-pubsub/src/nip98_replay.rs b/crates/buzz-pubsub/src/nip98_replay.rs index b51f1074f2..858f0f5ace 100644 --- a/crates/buzz-pubsub/src/nip98_replay.rs +++ b/crates/buzz-pubsub/src/nip98_replay.rs @@ -7,10 +7,9 @@ use buzz_auth::{ error::AuthError, nip98_replay::{ - nip98_replay_key, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS, + nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS, }, }; -use buzz_core::TenantContext; use nostr::EventId; /// Redis-backed NIP-98 replay seen-set. @@ -33,9 +32,9 @@ impl RedisNip98ReplayGuard { } impl Nip98ReplayGuard for RedisNip98ReplayGuard { - fn try_mark<'a>( + fn try_mark_in_scope<'a>( &'a self, - ctx: &'a TenantContext, + scope: &'a str, event_id: &'a EventId, ttl_secs: u64, ) -> std::pin::Pin> + Send + 'a>> @@ -52,14 +51,14 @@ impl Nip98ReplayGuard for RedisNip98ReplayGuard { // Structured field for ops; the user-facing AuthError stays a // bounded category string. tracing::warn!( - community = %ctx.community(), + scope = %scope, error = %e, "nip98 replay: redis pool acquire failed — caller MUST fail closed" ); AuthError::Internal(format!("Redis pool: {e}")) })?; - let key = nip98_replay_key(ctx, event_id); + let key = nip98_replay_key_for_scope(scope, event_id); // SET key 1 NX EX . redis-rs typed return: Some("OK") on first // claim, None on existing key. Any other value would be a Redis-side @@ -74,7 +73,7 @@ impl Nip98ReplayGuard for RedisNip98ReplayGuard { .await .map_err(|e| { tracing::warn!( - community = %ctx.community(), + scope = %scope, error = %e, "nip98 replay: redis SET NX EX failed — caller MUST fail closed" ); @@ -86,7 +85,7 @@ impl Nip98ReplayGuard for RedisNip98ReplayGuard { None => Ok(false), Some(other) => { tracing::error!( - community = %ctx.community(), + scope = %scope, reply = %other, "nip98 replay: redis SET NX EX returned an unexpected reply — investigate" ); diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml new file mode 100644 index 0000000000..aec3c43b02 --- /dev/null +++ b/crates/buzz-push-gateway/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "buzz-push-gateway" +description = "Blind, capability-gated NIP-PL gateway for the Buzz mobile app" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "buzz_push_gateway" +path = "src/lib.rs" + +[[bin]] +name = "buzz-push-gateway" +path = "src/main.rs" + +[dependencies] +aes-gcm = "0.10" +appattest = { version = "0.1.1", default-features = false } +axum = { workspace = true } +async-trait = "0.1" +base64 = "0.22" +byteorder = "1.5" +minicbor = "0.25" +chrono = { workspace = true } +hex = { workspace = true } +getrandom = "0.4" +metrics = { workspace = true } +metrics-exporter-prometheus = { workspace = true } +nostr = { workspace = true } +p256 = { version = "0.14", features = ["ecdsa", "pem", "pkcs8"] } +rand = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +reqwest = { workspace = true } diff --git a/crates/buzz-push-gateway/migrations/0001_push_gateway_authority.sql b/crates/buzz-push-gateway/migrations/0001_push_gateway_authority.sql new file mode 100644 index 0000000000..3fc079a63e --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0001_push_gateway_authority.sql @@ -0,0 +1,66 @@ +-- Durable, deployment-global authority for the public NIP-PL push gateway. +-- This state is intentionally outside relay community tenancy: installations +-- delegate to relay signing keys and may authorize multiple relay deployments. +CREATE TABLE push_gateway_challenges ( + id UUID PRIMARY KEY, + challenge_hash BYTEA NOT NULL CHECK (length(challenge_hash) = 32), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX push_gateway_challenges_expiry ON push_gateway_challenges (expires_at); + +CREATE TABLE push_gateway_installations ( + id UUID PRIMARY KEY, + app_attest_key_id BYTEA NOT NULL UNIQUE CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128), + app_attest_public_key BYTEA NOT NULL CHECK (octet_length(app_attest_public_key) BETWEEN 33 AND 256), + assertion_counter BIGINT NOT NULL CHECK (assertion_counter BETWEEN 0 AND 4294967295), + app_profile TEXT NOT NULL CHECK (app_profile IN ('buzz-ios-production','buzz-ios-sandbox')), + token_ciphertext BYTEA NOT NULL CHECK (octet_length(token_ciphertext) BETWEEN 1 AND 2048), + token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32), + endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (app_profile, token_fingerprint) +); +CREATE INDEX push_gateway_installations_expiry ON push_gateway_installations (expires_at) WHERE revoked_at IS NULL; + +CREATE TABLE push_gateway_delegations ( + id UUID PRIMARY KEY, + installation_id UUID NOT NULL REFERENCES push_gateway_installations(id), + relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), + endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0), + generation BIGINT NOT NULL CHECK (generation > 0), + not_before TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (installation_id, relay_pubkey), + CHECK (not_before < expires_at) +); +CREATE INDEX push_gateway_delegations_expiry ON push_gateway_delegations (expires_at) WHERE revoked_at IS NULL; + +CREATE TABLE push_gateway_endpoint_quotas ( + token_fingerprint BYTEA PRIMARY KEY CHECK (length(token_fingerprint) = 32), + window_started_at TIMESTAMPTZ NOT NULL, + admitted BIGINT NOT NULL CHECK (admitted >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX push_gateway_endpoint_quotas_updated ON push_gateway_endpoint_quotas (updated_at); + +CREATE TABLE push_gateway_delivery_auth_replays ( + relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), + auth_event_id BYTEA NOT NULL CHECK (length(auth_event_id) = 32), + expires_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (relay_pubkey, auth_event_id) +); +CREATE INDEX push_gateway_delivery_auth_replays_expiry ON push_gateway_delivery_auth_replays (expires_at); + +CREATE TABLE push_gateway_delivery_request_replays ( + relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), + request_id UUID NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (relay_pubkey, request_id) +); +CREATE INDEX push_gateway_delivery_request_replays_expiry ON push_gateway_delivery_request_replays (expires_at); diff --git a/crates/buzz-push-gateway/src/apns.rs b/crates/buzz-push-gateway/src/apns.rs new file mode 100644 index 0000000000..8f6f182000 --- /dev/null +++ b/crates/buzz-push-gateway/src/apns.rs @@ -0,0 +1,367 @@ +//! APNs envelope construction, endpoint encryption, and response classification. + +use std::{sync::Mutex, time::Duration}; + +use async_trait::async_trait; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use p256::{ + ecdsa::{signature::Signer, Signature, SigningKey}, + pkcs8::DecodePrivateKey, +}; +use reqwest::{ + header::{AUTHORIZATION, CONTENT_TYPE}, + StatusCode, +}; +use serde::Deserialize; +use thiserror::Error; + +use crate::model::{AppProfile, APNS_RECONNECT_PAYLOAD}; + +/// Sanitized delivery outcome. Raw provider bodies never cross this boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryOutcome { + /// APNs accepted the request (not proof of device delivery). + Accepted, + /// This endpoint generation is permanently invalid. APNs may provide the time it became invalid. + InvalidEndpoint { + /// APNs' timestamp for when the endpoint became invalid, if supplied. + unregistered_at: Option, + }, + /// A bounded retry is safe. A sanitized server hint may raise the delay. + Retry { + /// Retry-After delay in seconds, clamped by the transport. + retry_after_seconds: Option, + }, + /// Refresh the cached provider JWT, then retry once within normal attempt bounds. + RefreshCredential, + /// Provider credential/profile configuration is unhealthy; do not invalidate endpoints. + ConfigurationFault, + /// The locally-generated request is permanently invalid. + PermanentRequestFault, +} + +/// Classify APNs status/reason without conflating provider faults with endpoints. +pub fn classify(code: u16, reason: Option<&str>, timestamp: Option) -> DeliveryOutcome { + match (code, reason) { + (200, _) => DeliveryOutcome::Accepted, + (410, Some("Unregistered")) => DeliveryOutcome::InvalidEndpoint { + unregistered_at: timestamp, + }, + (400, Some("BadDeviceToken" | "DeviceTokenNotForTopic")) => { + DeliveryOutcome::InvalidEndpoint { + unregistered_at: None, + } + } + (403, Some("ExpiredProviderToken")) => DeliveryOutcome::RefreshCredential, + (403, _) | (429, Some("TooManyProviderTokenUpdates")) => { + DeliveryOutcome::ConfigurationFault + } + (429 | 500 | 503, _) + | ( + _, + Some( + "IdleTimeout" + | "InternalServerError" + | "ServiceUnavailable" + | "Shutdown" + | "TooManyRequests", + ), + ) => DeliveryOutcome::Retry { + retry_after_seconds: None, + }, + _ => DeliveryOutcome::PermanentRequestFault, + } +} + +/// Closed APNs transport controls. No field can be serialized into application +/// content; the concrete transport always uses `APNS_RECONNECT_PAYLOAD`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeliveryAttempt { + pub request_id: uuid::Uuid, + pub expires_at: i64, +} + +/// APNs sender abstraction for live-validation tests. +#[async_trait] +pub trait PushTransport: Send + Sync { + /// Send one durable job. + async fn send( + &self, + attempt: DeliveryAttempt, + profile: AppProfile, + endpoint: &str, + ) -> DeliveryOutcome; + /// Discard a cached credential after APNs reports expiry. + fn refresh_credential(&self) {} +} + +struct CachedJwt { + token: String, + issued_at: i64, +} + +/// Direct HTTP/2 APNs transport using a cached ES256 provider token. +pub struct ApnsTransport { + client: reqwest::Client, + signing_key: SigningKey, + key_id: String, + team_id: String, + topic: String, + production_base_url: String, + sandbox_base_url: String, + cached_jwt: Mutex>, +} + +impl ApnsTransport { + /// Build a reusable APNs client from an Apple `.p8` private key. + pub fn token(p8: &[u8], key_id: &str, team_id: &str, topic: String) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .map_err(|_| ApnsError::Client)?; + Self::token_with_client( + p8, + key_id, + team_id, + topic, + client, + "https://api.push.apple.com".to_owned(), + "https://api.sandbox.push.apple.com".to_owned(), + ) + } + + fn token_with_client( + p8: &[u8], + key_id: &str, + team_id: &str, + topic: String, + client: reqwest::Client, + production_base_url: String, + sandbox_base_url: String, + ) -> Result { + let pem = std::str::from_utf8(p8).map_err(|_| ApnsError::Credential)?; + let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|_| ApnsError::Credential)?; + Ok(Self { + client, + signing_key, + key_id: key_id.to_owned(), + team_id: team_id.to_owned(), + topic, + production_base_url, + sandbox_base_url, + cached_jwt: Mutex::new(None), + }) + } + + fn jwt(&self, now: i64) -> Result { + let mut cached = self.cached_jwt.lock().map_err(|_| ApnsError::Credential)?; + if let Some(jwt) = cached.as_ref().filter(|jwt| now - jwt.issued_at < 50 * 60) { + return Ok(jwt.token.clone()); + } + let header = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&serde_json::json!({"alg":"ES256","kid":self.key_id})) + .map_err(|_| ApnsError::Credential)?, + ); + let claims = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&serde_json::json!({"iss":self.team_id,"iat":now})) + .map_err(|_| ApnsError::Credential)?, + ); + let signing_input = format!("{header}.{claims}"); + let signature: Signature = self.signing_key.sign(signing_input.as_bytes()); + let token = format!( + "{signing_input}.{}", + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + ); + *cached = Some(CachedJwt { + token: token.clone(), + issued_at: now, + }); + Ok(token) + } +} + +/// APNs transport setup failure. It intentionally carries no credential material. +#[derive(Debug, Error)] +pub enum ApnsError { + /// Invalid provider key material. + #[error("invalid APNs credential")] + Credential, + /// HTTP client setup failed. + #[error("failed to construct APNs client")] + Client, +} + +#[derive(Deserialize)] +struct ApnsErrorBody { + reason: Option, + timestamp: Option, +} + +#[async_trait] +impl PushTransport for ApnsTransport { + async fn send( + &self, + attempt: DeliveryAttempt, + profile: AppProfile, + endpoint: &str, + ) -> DeliveryOutcome { + // This is the only APNs application body in the program. It is a + // byte constant, not a serialization of the relay request, grant, + // endpoint, headers, route, provider response, or any generic JSON map. + let body = APNS_RECONNECT_PAYLOAD; + let now = chrono::Utc::now().timestamp(); + let token = match self.jwt(now) { + Ok(token) => token, + Err(_) => return DeliveryOutcome::ConfigurationFault, + }; + let base_url = match profile { + AppProfile::BuzzIosProduction => &self.production_base_url, + AppProfile::BuzzIosSandbox => &self.sandbox_base_url, + }; + let response = self + .client + .post(format!("{base_url}/3/device/{endpoint}")) + .header(AUTHORIZATION, format!("bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .header("apns-id", attempt.request_id.to_string()) + .header("apns-topic", &self.topic) + .header("apns-push-type", "alert") + .header("apns-priority", "10") + .header("apns-expiration", attempt.expires_at.to_string()) + .body(body) + .send() + .await; + let response = match response { + Ok(response) => response, + Err(_) => { + return DeliveryOutcome::Retry { + retry_after_seconds: None, + } + } + }; + if response.status() == StatusCode::OK { + return DeliveryOutcome::Accepted; + } + let code = response.status().as_u16(); + let retry_after = response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(|seconds| seconds.clamp(1, 3600)); + let detail = response.json::().await.ok(); + let timestamp = detail.as_ref().and_then(|d| d.timestamp); + match classify( + code, + detail.as_ref().and_then(|d| d.reason.as_deref()), + timestamp, + ) { + DeliveryOutcome::Retry { .. } => DeliveryOutcome::Retry { + retry_after_seconds: retry_after, + }, + outcome => outcome, + } + } + + fn refresh_credential(&self) { + if let Ok(mut cached) = self.cached_jwt.lock() { + *cached = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Bytes, extract::State, http::StatusCode, routing::post, Router}; + use p256::pkcs8::{EncodePrivateKey, LineEnding}; + use std::sync::Arc; + + async fn capture_body( + State(bodies): State>>>>, + body: Bytes, + ) -> StatusCode { + bodies.lock().unwrap().push(body.to_vec()); + StatusCode::OK + } + #[tokio::test] + async fn real_outbound_http_body_is_the_exact_constant_for_every_attempt() { + let bodies = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route("/3/device/{endpoint}", post(capture_body)) + .with_state(bodies.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let signing_key = SigningKey::from_slice(&[7; 32]).unwrap(); + let pem = signing_key.to_pkcs8_pem(LineEnding::LF).unwrap(); + let transport = ApnsTransport::token_with_client( + pem.as_bytes(), + "kid", + "team", + "app.topic".to_owned(), + reqwest::Client::new(), + base_url.clone(), + base_url, + ) + .unwrap(); + for (request_id, expires_at, profile, endpoint) in [ + ( + uuid::Uuid::nil(), + 1, + AppProfile::BuzzIosProduction, + "00".repeat(32), + ), + ( + uuid::Uuid::max(), + i64::MAX, + AppProfile::BuzzIosSandbox, + "ff".repeat(32), + ), + ] { + assert_eq!( + transport + .send( + DeliveryAttempt { + request_id, + expires_at, + }, + profile, + &endpoint, + ) + .await, + DeliveryOutcome::Accepted + ); + } + let captured = bodies.lock().unwrap(); + assert_eq!(captured.len(), 2); + assert!(captured + .iter() + .all(|body| body.as_slice() == APNS_RECONNECT_PAYLOAD)); + } + + #[test] + fn response_classes_do_not_massacre_endpoints_on_provider_faults() { + assert_eq!( + classify(410, Some("Unregistered"), Some(7)), + DeliveryOutcome::InvalidEndpoint { + unregistered_at: Some(7) + } + ); + assert_eq!( + classify(403, Some("InvalidProviderToken"), None), + DeliveryOutcome::ConfigurationFault + ); + assert_eq!( + classify(429, Some("TooManyRequests"), None), + DeliveryOutcome::Retry { + retry_after_seconds: None + } + ); + assert_eq!( + classify(400, Some("BadTopic"), None), + DeliveryOutcome::PermanentRequestFault + ); + } +} diff --git a/crates/buzz-push-gateway/src/app_attest.rs b/crates/buzz-push-gateway/src/app_attest.rs new file mode 100644 index 0000000000..ebb1fc56bc --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest.rs @@ -0,0 +1,140 @@ +//! Narrow App Attest verification boundary. Production enrollment accepts only +//! Apple production AAGUID material; unsupported devices have no bypass path. +use appattest::{assertion::Assertion, attestation::Attestation}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use byteorder::{BigEndian, ByteOrder}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +const MAX_ATTESTATION_BYTES: usize = 16 * 1024; +const MAX_ASSERTION_BYTES: usize = 1024; +const APPLE_APP_ATTEST_ROOT_PEM_SHA256: [u8; 32] = [ + 0xc7, 0x78, 0xd0, 0x9a, 0xc3, 0x41, 0xf7, 0xfd, 0x9f, 0x8f, 0x3b, 0x19, 0xe2, 0xb8, 0x15, 0xaf, + 0x6a, 0xed, 0x4a, 0xd4, 0x49, 0x0e, 0x1e, 0x92, 0xc0, 0x5c, 0xb3, 0x55, 0x21, 0x2a, 0x50, 0x13, +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedAttestation { + pub key_id: Vec, + pub public_key: Vec, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VerifiedAssertion { + pub counter: u32, +} +#[derive(Debug, Error)] +pub enum AppAttestError { + #[error("invalid app attestation or assertion")] + Invalid, +} + +#[derive(Clone)] +pub struct AppAttestVerifier { + app_id: String, + apple_root_cert_pem: Vec, +} +impl AppAttestVerifier { + pub fn new(app_id: String, apple_root_cert_pem: Vec) -> Result { + if app_id.is_empty() + || Sha256::digest(&apple_root_cert_pem).as_slice() != APPLE_APP_ATTEST_ROOT_PEM_SHA256 + { + return Err(AppAttestError::Invalid); + } + Ok(Self { + app_id, + apple_root_cert_pem, + }) + } + /// `client_data` is the exact canonical enrollment transcript represented by + /// the challenge string passed to `attestKey`; callers must include every + /// authority-bearing enrollment field in it. + pub fn verify_attestation( + &self, + attestation_b64: &str, + key_id_b64: &str, + client_data: &[u8], + ) -> Result { + let cbor = STANDARD + .decode(attestation_b64) + .map_err(|_| AppAttestError::Invalid)?; + if cbor.is_empty() || cbor.len() > MAX_ATTESTATION_BYTES { + return Err(AppAttestError::Invalid); + } + let challenge = std::str::from_utf8(client_data).map_err(|_| AppAttestError::Invalid)?; + let att = Attestation::from_cbor_bytes(&cbor).map_err(|_| AppAttestError::Invalid)?; + let (public_key, _) = att + .verify( + challenge, + &self.app_id, + key_id_b64, + &self.apple_root_cert_pem, + ) + .map_err(|_| AppAttestError::Invalid)?; + let key_id = STANDARD + .decode(key_id_b64) + .map_err(|_| AppAttestError::Invalid)?; + if key_id.len() != 32 { + return Err(AppAttestError::Invalid); + } + Ok(VerifiedAttestation { + key_id, + public_key: public_key.to_vec(), + }) + } + pub fn verify_assertion( + &self, + assertion_b64: &str, + client_data: &[u8], + public_key: &[u8], + previous_counter: u32, + challenge: &str, + stored_challenge: &str, + ) -> Result { + let cbor = STANDARD + .decode(assertion_b64) + .map_err(|_| AppAttestError::Invalid)?; + if cbor.is_empty() || cbor.len() > MAX_ASSERTION_BYTES { + return Err(AppAttestError::Invalid); + } + let counter = assertion_counter(&cbor)?; + let client_data_hash = Sha256::digest(client_data); + Assertion::from_assertion(&cbor) + .map_err(|_| AppAttestError::Invalid)? + .verify( + client_data_hash, + challenge, + &self.app_id, + public_key, + previous_counter, + stored_challenge, + ) + .map_err(|_| AppAttestError::Invalid)?; + Ok(VerifiedAssertion { counter }) + } +} + +/// App Attest assertion CBOR is a closed two-field map. Extracting signCount +/// from authenticatorData is safe only after the library verifies the same +/// bytes' RP ID, signature, and monotonic relation. +fn assertion_counter(cbor: &[u8]) -> Result { + let mut d = minicbor::Decoder::new(cbor); + let count = d + .map() + .map_err(|_| AppAttestError::Invalid)? + .ok_or(AppAttestError::Invalid)?; + let mut auth = None; + for _ in 0..count { + let k = d.str().map_err(|_| AppAttestError::Invalid)?; + match k { + "authenticatorData" => auth = Some(d.bytes().map_err(|_| AppAttestError::Invalid)?), + "signature" => { + d.bytes().map_err(|_| AppAttestError::Invalid)?; + } + _ => return Err(AppAttestError::Invalid), + } + } + let auth = auth + .filter(|a| a.len() == 37) + .ok_or(AppAttestError::Invalid)?; + Ok(BigEndian::read_u32(&auth[33..37])) +} diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs new file mode 100644 index 0000000000..36c220885c --- /dev/null +++ b/crates/buzz-push-gateway/src/authority.rs @@ -0,0 +1,576 @@ +//! Durable installation authority and relay delegation state machine. +//! +//! App Attest authenticates the app instance and exact request transcript. It +//! does not prove an Apple-issued binding between that key and the APNs token; +//! accepting the directly submitted token is the protocol's explicit bootstrap +//! assumption. +use crate::model::AppProfile; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Mutex; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Challenge { + pub id: Uuid, + pub value: [u8; 32], + pub expires_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewInstallation { + pub id: Uuid, + pub app_attest_key_id: Vec, + pub app_attest_public_key: Vec, + pub assertion_counter: u32, + pub profile: AppProfile, + pub token_ciphertext: Vec, + pub token_fingerprint: [u8; 32], + pub endpoint_epoch: i64, + pub expires_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Installation { + pub id: Uuid, + pub app_attest_key_id: Vec, + pub app_attest_public_key: Vec, + pub assertion_counter: u32, + pub profile: AppProfile, + pub token_ciphertext: Vec, + pub token_fingerprint: [u8; 32], + pub endpoint_epoch: i64, + pub expires_at: i64, + pub revoked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Delegation { + pub id: Uuid, + pub installation_id: Uuid, + pub relay_pubkey: String, + pub endpoint_epoch: i64, + pub generation: i64, + pub not_before: i64, + pub expires_at: i64, + pub revoked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeliveryAuthority { + pub delegation_id: Uuid, + pub installation_id: Uuid, + pub relay_pubkey: String, + pub profile: AppProfile, + pub token_ciphertext: Vec, + pub endpoint_epoch: i64, + pub generation: i64, + pub expires_at: i64, +} + +#[derive(Debug)] +pub struct DeliveryPermit { + pub authority: DeliveryAuthority, + pub relay_pubkey: String, + pub request_id: Uuid, +} + +impl DeliveryPermit { + pub fn new(authority: DeliveryAuthority, relay_pubkey: String, request_id: Uuid) -> Self { + Self { + authority, + relay_pubkey, + request_id, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryDisposition { + Terminal, + Retryable, +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum AuthorityError { + #[error("authority state rejected the request")] + Rejected, + #[error("authority store unavailable")] + Unavailable, +} + +/// Every mutating method is an atomic store operation. Implementations must +/// serialize installation assertion counters and delegation generations. +#[async_trait] +pub trait AuthorityStore: Send + Sync { + /// Readiness must fail closed when durable authority cannot participate. + async fn ready(&self) -> Result<(), AuthorityError>; + async fn put_challenge(&self, challenge: Challenge) -> Result<(), AuthorityError>; + async fn consume_challenge( + &self, + id: Uuid, + value: [u8; 32], + now: i64, + ) -> Result<(), AuthorityError>; + async fn create_installation( + &self, + installation: NewInstallation, + ) -> Result<(), AuthorityError>; + async fn installation(&self, id: Uuid, now: i64) -> Result; + async fn advance_assertion_counter( + &self, + installation_id: Uuid, + previous: u32, + next: u32, + ) -> Result<(), AuthorityError>; + async fn upsert_delegation(&self, delegation: Delegation) -> Result<(), AuthorityError>; + async fn rotate_endpoint( + &self, + installation_id: Uuid, + expected_epoch: i64, + new_epoch: i64, + token_ciphertext: Vec, + token_fingerprint: [u8; 32], + ) -> Result<(), AuthorityError>; + async fn revoke_delegation( + &self, + installation_id: Uuid, + relay_pubkey: &str, + new_generation: i64, + ) -> Result<(), AuthorityError>; + async fn revoke_installation( + &self, + installation_id: Uuid, + expected_epoch: i64, + new_epoch: i64, + ) -> Result<(), AuthorityError>; + /// Atomically validate and lock installation then delegation authority, + /// reserve quota/replay state, and commit. That durable commit is the + /// delivery send-begin linearization point seen by revocation. + #[allow(clippy::too_many_arguments)] + async fn authorize_delivery( + &self, + delegation_id: Uuid, + relay_pubkey: &str, + endpoint_epoch: i64, + generation: i64, + event_id: &str, + request_id: Uuid, + request_expires_at: i64, + quota_window_seconds: i64, + quota_max_deliveries: i64, + now: i64, + ) -> Result; + /// Retain terminal request ids; release retryable request ids while always + /// retaining the one-use auth event admitted by `authorize_delivery`. + async fn finish_delivery( + &self, + permit: DeliveryPermit, + disposition: DeliveryDisposition, + ) -> Result<(), AuthorityError>; + /// Delete only data whose safety retention window has elapsed. + async fn reap_expired(&self, now: i64) -> Result<(), AuthorityError>; +} + +#[derive(Default)] +struct MemoryState { + challenges: HashMap, + installations: HashMap, + token_owners: HashMap<(AppProfile, [u8; 32]), Uuid>, + delegations: HashMap<(Uuid, String), Delegation>, + delegation_ids: HashMap, + delivery_auth_replays: HashMap<(String, String), i64>, + delivery_request_replays: HashMap<(String, Uuid), i64>, + endpoint_quotas: HashMap<[u8; 32], (i64, i64)>, +} + +/// Executable reference store used by conformance tests. Production uses the +/// PostgreSQL implementation; this lock deliberately gives the model a single +/// linearization point for every authority transition. +#[derive(Default)] +pub struct MemoryAuthorityStore(Mutex); + +#[async_trait] +impl AuthorityStore for MemoryAuthorityStore { + async fn ready(&self) -> Result<(), AuthorityError> { + self.0 + .lock() + .map(|_| ()) + .map_err(|_| AuthorityError::Unavailable) + } + + async fn put_challenge(&self, challenge: Challenge) -> Result<(), AuthorityError> { + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + if s.challenges.insert(challenge.id, challenge).is_some() { + return Err(AuthorityError::Rejected); + } + Ok(()) + } + + async fn consume_challenge( + &self, + id: Uuid, + value: [u8; 32], + now: i64, + ) -> Result<(), AuthorityError> { + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let challenge = s.challenges.remove(&id).ok_or(AuthorityError::Rejected)?; + if challenge.value != value || challenge.expires_at < now { + return Err(AuthorityError::Rejected); + } + Ok(()) + } + + async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> { + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let token_key = (n.profile, n.token_fingerprint); + if s.installations.contains_key(&n.id) || s.token_owners.contains_key(&token_key) { + // Token possession alone never supersedes a live installation. + return Err(AuthorityError::Rejected); + } + s.token_owners.insert(token_key, n.id); + s.installations.insert( + n.id, + Installation { + id: n.id, + app_attest_key_id: n.app_attest_key_id, + app_attest_public_key: n.app_attest_public_key, + assertion_counter: n.assertion_counter, + profile: n.profile, + token_ciphertext: n.token_ciphertext, + token_fingerprint: n.token_fingerprint, + endpoint_epoch: n.endpoint_epoch, + expires_at: n.expires_at, + revoked: false, + }, + ); + Ok(()) + } + + async fn installation(&self, id: Uuid, now: i64) -> Result { + let s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let i = s + .installations + .get(&id) + .filter(|i| !i.revoked && i.expires_at >= now) + .ok_or(AuthorityError::Rejected)?; + Ok(i.clone()) + } + + async fn advance_assertion_counter( + &self, + id: Uuid, + previous: u32, + next: u32, + ) -> Result<(), AuthorityError> { + if next <= previous { + return Err(AuthorityError::Rejected); + } + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let i = s + .installations + .get_mut(&id) + .ok_or(AuthorityError::Rejected)?; + if i.revoked || i.assertion_counter != previous { + return Err(AuthorityError::Rejected); + } + i.assertion_counter = next; + Ok(()) + } + + async fn upsert_delegation(&self, d: Delegation) -> Result<(), AuthorityError> { + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let i = s + .installations + .get(&d.installation_id) + .ok_or(AuthorityError::Rejected)?; + if i.revoked + || i.endpoint_epoch != d.endpoint_epoch + || d.generation < 1 + || d.not_before >= d.expires_at + || d.expires_at > i.expires_at + { + return Err(AuthorityError::Rejected); + } + let key = (d.installation_id, d.relay_pubkey.clone()); + if s.delegations + .get(&key) + .is_some_and(|old| d.generation <= old.generation) + || s.delegation_ids.contains_key(&d.id) + { + return Err(AuthorityError::Rejected); + } + s.delegation_ids.insert(d.id, key.clone()); + s.delegations.insert(key, d); + Ok(()) + } + + async fn rotate_endpoint( + &self, + id: Uuid, + expected: i64, + new: i64, + ciphertext: Vec, + fingerprint: [u8; 32], + ) -> Result<(), AuthorityError> { + if new != expected.checked_add(1).ok_or(AuthorityError::Rejected)? { + return Err(AuthorityError::Rejected); + } + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let (profile, old_fingerprint) = { + let i = s.installations.get(&id).ok_or(AuthorityError::Rejected)?; + if i.revoked || i.endpoint_epoch != expected { + return Err(AuthorityError::Rejected); + } + (i.profile, i.token_fingerprint) + }; + let token_key = (profile, fingerprint); + if s.token_owners + .get(&token_key) + .is_some_and(|owner| *owner != id) + { + return Err(AuthorityError::Rejected); + } + s.token_owners.remove(&(profile, old_fingerprint)); + s.token_owners.insert(token_key, id); + let i = s + .installations + .get_mut(&id) + .ok_or(AuthorityError::Rejected)?; + i.endpoint_epoch = new; + i.token_ciphertext = ciphertext; + i.token_fingerprint = fingerprint; + Ok(()) + } + + async fn revoke_delegation( + &self, + id: Uuid, + relay: &str, + generation: i64, + ) -> Result<(), AuthorityError> { + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let key = (id, relay.to_owned()); + let old = s + .delegations + .get_mut(&key) + .ok_or(AuthorityError::Rejected)?; + if generation <= old.generation { + return Err(AuthorityError::Rejected); + } + old.generation = generation; + old.revoked = true; + Ok(()) + } + + async fn revoke_installation( + &self, + id: Uuid, + expected: i64, + new: i64, + ) -> Result<(), AuthorityError> { + if new != expected.checked_add(1).ok_or(AuthorityError::Rejected)? { + return Err(AuthorityError::Rejected); + } + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let i = s + .installations + .get_mut(&id) + .ok_or(AuthorityError::Rejected)?; + if i.revoked || i.endpoint_epoch != expected { + return Err(AuthorityError::Rejected); + } + i.endpoint_epoch = new; + i.revoked = true; + Ok(()) + } + + async fn authorize_delivery( + &self, + delegation_id: Uuid, + relay: &str, + epoch: i64, + generation: i64, + event_id: &str, + request_id: Uuid, + request_expires_at: i64, + quota_window_seconds: i64, + quota_max_deliveries: i64, + now: i64, + ) -> Result { + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let key = s + .delegation_ids + .get(&delegation_id) + .cloned() + .ok_or(AuthorityError::Rejected)?; + let d = s.delegations.get(&key).ok_or(AuthorityError::Rejected)?; + let i = s + .installations + .get(&d.installation_id) + .ok_or(AuthorityError::Rejected)?; + if d.revoked + || i.revoked + || d.id != delegation_id + || d.relay_pubkey != relay + || d.endpoint_epoch != epoch + || d.generation != generation + || i.endpoint_epoch != epoch + || now < d.not_before + || now > d.expires_at + || now > i.expires_at + || request_expires_at < now + || request_expires_at > d.expires_at + { + return Err(AuthorityError::Rejected); + } + let authority = DeliveryAuthority { + delegation_id, + installation_id: i.id, + relay_pubkey: relay.to_owned(), + profile: i.profile, + token_ciphertext: i.token_ciphertext.clone(), + endpoint_epoch: epoch, + generation, + expires_at: d.expires_at, + }; + let fingerprint = i.token_fingerprint; + let auth_key = (relay.to_owned(), event_id.to_owned()); + let request_key = (relay.to_owned(), request_id); + if s.delivery_auth_replays.contains_key(&auth_key) + || s.delivery_request_replays.contains_key(&request_key) + { + return Err(AuthorityError::Rejected); + } + let quota = s.endpoint_quotas.entry(fingerprint).or_insert((now, 0)); + if now.saturating_sub(quota.0) >= quota_window_seconds { + *quota = (now, 0); + } + if quota.1 >= quota_max_deliveries { + return Err(AuthorityError::Rejected); + } + quota.1 += 1; + s.delivery_auth_replays.insert(auth_key, request_expires_at); + s.delivery_request_replays + .insert(request_key, request_expires_at); + Ok(DeliveryPermit::new(authority, relay.to_owned(), request_id)) + } + + async fn finish_delivery( + &self, + permit: DeliveryPermit, + disposition: DeliveryDisposition, + ) -> Result<(), AuthorityError> { + if disposition == DeliveryDisposition::Retryable { + self.0 + .lock() + .map_err(|_| AuthorityError::Unavailable)? + .delivery_request_replays + .remove(&(permit.relay_pubkey, permit.request_id)); + } + Ok(()) + } + + async fn reap_expired(&self, now: i64) -> Result<(), AuthorityError> { + let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + s.challenges + .retain(|_, challenge| challenge.expires_at >= now); + s.delivery_auth_replays + .retain(|_, expires_at| *expires_at >= now); + s.delivery_request_replays + .retain(|_, expires_at| *expires_at >= now); + s.endpoint_quotas + .retain(|_, (started_at, _)| now.saturating_sub(*started_at) < 86_400); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn admitted( + store: &MemoryAuthorityStore, + event: &str, + request: Uuid, + ) -> Result { + store + .authorize_delivery( + Uuid::from_u128(2), + &"11".repeat(32), + 1, + 1, + event, + request, + 1_100, + 60, + 10, + 1_000, + ) + .await + } + + async fn store() -> MemoryAuthorityStore { + let store = MemoryAuthorityStore::default(); + store + .create_installation(NewInstallation { + id: Uuid::from_u128(1), + app_attest_key_id: vec![1], + app_attest_public_key: vec![2; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosProduction, + token_ciphertext: vec![3], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: 2_000, + }) + .await + .unwrap(); + store + .upsert_delegation(Delegation { + id: Uuid::from_u128(2), + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation: 1, + not_before: 900, + expires_at: 1_500, + revoked: false, + }) + .await + .unwrap(); + store + } + + #[tokio::test] + async fn retry_releases_request_id_but_burns_auth_event() { + let store = store().await; + let request = Uuid::new_v4(); + let first_event = "22".repeat(32); + let permit = admitted(&store, &first_event, request).await.unwrap(); + store + .finish_delivery(permit, DeliveryDisposition::Retryable) + .await + .unwrap(); + + assert!(admitted(&store, &first_event, Uuid::new_v4()) + .await + .is_err()); + admitted(&store, &"33".repeat(32), request) + .await + .expect("fresh auth event may retry stable request id"); + } + + #[tokio::test] + async fn terminal_outcome_burns_request_id() { + let store = store().await; + let request = Uuid::new_v4(); + let permit = admitted(&store, &"22".repeat(32), request).await.unwrap(); + store + .finish_delivery(permit, DeliveryDisposition::Terminal) + .await + .unwrap(); + assert!(admitted(&store, &"33".repeat(32), request).await.is_err()); + } +} diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs new file mode 100644 index 0000000000..c6194edbcb --- /dev/null +++ b/crates/buzz-push-gateway/src/config.rs @@ -0,0 +1,297 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use std::{ + collections::{HashMap, HashSet}, + net::SocketAddr, + path::PathBuf, +}; +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyConfig { + pub id: String, + pub key: Vec, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub bind_addr: SocketAddr, + pub health_addr: SocketAddr, + pub public_delivery_url: url::Url, + pub max_grant_lifetime_seconds: i64, + pub max_installation_lifetime_seconds: i64, + pub endpoint_quota_window_seconds: i64, + pub endpoint_quota_max_deliveries: i64, + pub enabled_profiles: HashSet, + pub database_url: String, + pub app_attest_app_id: String, + pub app_attest_root_cert_path: PathBuf, + /// Ordered current key first, followed by decrypt-only predecessors. + pub grant_keys: Vec, + /// Independent token-custody keyring. These keys MUST NOT be reused for + /// externally presented delivery capabilities. + pub token_keys: Vec, + pub apns_key_path: PathBuf, + pub apns_key_id: String, + pub apns_team_id: String, + pub apns_topic: String, +} +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("missing required environment variable {0}")] + Missing(&'static str), + #[error("invalid environment variable {0}")] + Invalid(&'static str), +} +fn parse_keyring( + e: &HashMap, + variable: &'static str, +) -> Result, ConfigError> { + let value = e + .get(variable) + .map(String::as_str) + .filter(|value| !value.is_empty()) + .ok_or(ConfigError::Missing(variable))?; + let keys = value + .split(',') + .map(|entry| { + let (id, encoded) = entry + .split_once(':') + .filter(|(id, encoded)| !id.is_empty() && !encoded.is_empty()) + .ok_or(ConfigError::Invalid(variable))?; + let key = STANDARD + .decode(encoded) + .map_err(|_| ConfigError::Invalid(variable))?; + if key.len() != 32 { + return Err(ConfigError::Invalid(variable)); + } + Ok(KeyConfig { + id: id.to_owned(), + key, + }) + }) + .collect::, _>>()?; + if keys.is_empty() { + return Err(ConfigError::Invalid(variable)); + } + Ok(keys) +} +impl Config { + pub fn from_env() -> Result { + Self::from_map(&std::env::vars().collect()) + } + pub fn from_map(e: &HashMap) -> Result { + fn req<'a>( + e: &'a HashMap, + k: &'static str, + ) -> Result<&'a str, ConfigError> { + e.get(k) + .map(String::as_str) + .filter(|v| !v.is_empty()) + .ok_or(ConfigError::Missing(k)) + } + let grant_keys = parse_keyring(e, "BUZZ_PUSH_GRANT_KEYS")?; + let token_keys = parse_keyring(e, "BUZZ_PUSH_TOKEN_KEYS")?; + if grant_keys.iter().any(|grant| { + token_keys + .iter() + .any(|token| grant.id == token.id || grant.key == token.key) + }) { + return Err(ConfigError::Invalid("BUZZ_PUSH_TOKEN_KEYS")); + } + let public_delivery_url = req(e, "BUZZ_PUSH_PUBLIC_DELIVERY_URL")? + .parse::() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_PUBLIC_DELIVERY_URL"))?; + if public_delivery_url.scheme() != "https" + || public_delivery_url.host_str() != Some("push.buzz.xyz") + || public_delivery_url.port().is_some() + || public_delivery_url.path() != "/v1/deliveries/apns" + || public_delivery_url.query().is_some() + || public_delivery_url.fragment().is_some() + || !public_delivery_url.username().is_empty() + || public_delivery_url.password().is_some() + { + return Err(ConfigError::Invalid("BUZZ_PUSH_PUBLIC_DELIVERY_URL")); + } + let max_grant_lifetime_seconds = req(e, "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS")? + .parse::() + .ok() + .filter(|seconds| (1..=31_536_000).contains(seconds)) + .ok_or(ConfigError::Invalid("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS"))?; + let max_installation_lifetime_seconds = e + .get("BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS") + .map(String::as_str) + .unwrap_or("7776000") + .parse::() + .ok() + .filter(|seconds| (1..=31_536_000).contains(seconds)) + .ok_or(ConfigError::Invalid( + "BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS", + ))?; + let bounded_positive = |key: &'static str, default: i64, max: i64| { + e.get(key) + .map(String::as_str) + .unwrap_or("") + .parse::() + .ok() + .or((!e.contains_key(key)).then_some(default)) + .filter(|value| (1..=max).contains(value)) + .ok_or(ConfigError::Invalid(key)) + }; + let endpoint_quota_window_seconds = + bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS", 10, 86_400)?; + let endpoint_quota_max_deliveries = + bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES", 10, 10_000)?; + let enabled_profiles = req(e, "BUZZ_PUSH_ENABLED_PROFILES")? + .split(',') + .map(|profile| match profile { + "buzz-ios-production" => Ok(crate::model::AppProfile::BuzzIosProduction), + "buzz-ios-sandbox" => Ok(crate::model::AppProfile::BuzzIosSandbox), + _ => Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")), + }) + .collect::, _>>()?; + if enabled_profiles.is_empty() { + return Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")); + } + Ok(Self { + bind_addr: e + .get("BUZZ_PUSH_BIND_ADDR") + .map(String::as_str) + .unwrap_or("0.0.0.0:8080") + .parse() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_BIND_ADDR"))?, + health_addr: e + .get("BUZZ_PUSH_HEALTH_ADDR") + .map(String::as_str) + .unwrap_or("0.0.0.0:8081") + .parse() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?, + public_delivery_url, + max_grant_lifetime_seconds, + max_installation_lifetime_seconds, + endpoint_quota_window_seconds, + endpoint_quota_max_deliveries, + enabled_profiles, + database_url: req(e, "DATABASE_URL")?.to_owned(), + app_attest_app_id: req(e, "BUZZ_PUSH_APP_ATTEST_APP_ID")?.to_owned(), + app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(), + grant_keys, + token_keys, + apns_key_path: req(e, "BUZZ_PUSH_APNS_KEY_PATH")?.into(), + apns_key_id: req(e, "BUZZ_PUSH_APNS_KEY_ID")?.to_owned(), + apns_team_id: req(e, "BUZZ_PUSH_APNS_TEAM_ID")?.to_owned(), + apns_topic: req(e, "BUZZ_PUSH_APNS_TOPIC")?.to_owned(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base() -> HashMap { + HashMap::from([ + ( + "BUZZ_PUSH_GRANT_KEYS".into(), + format!( + "current:{},old:{}", + STANDARD.encode([1; 32]), + STANDARD.encode([2; 32]) + ), + ), + ( + "BUZZ_PUSH_TOKEN_KEYS".into(), + format!( + "current-token:{},old-token:{}", + STANDARD.encode([3; 32]), + STANDARD.encode([4; 32]) + ), + ), + ( + "BUZZ_PUSH_PUBLIC_DELIVERY_URL".into(), + "https://push.buzz.xyz/v1/deliveries/apns".into(), + ), + ( + "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS".into(), + "2592000".into(), + ), + ( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-production".into(), + ), + ( + "DATABASE_URL".into(), + "postgres://buzz:test@localhost/buzz".into(), + ), + ("BUZZ_PUSH_APP_ATTEST_APP_ID".into(), "TEAM.app".into()), + ( + "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH".into(), + "/apple-root.pem".into(), + ), + ("BUZZ_PUSH_APNS_KEY_PATH".into(), "/key.p8".into()), + ("BUZZ_PUSH_APNS_KEY_ID".into(), "key".into()), + ("BUZZ_PUSH_APNS_TEAM_ID".into(), "team".into()), + ("BUZZ_PUSH_APNS_TOPIC".into(), "app".into()), + ]) + } + + #[test] + fn keyrings_preserve_current_then_predecessor_order_and_are_independent() { + let config = Config::from_map(&base()).unwrap(); + assert_eq!(config.grant_keys[0].id, "current"); + assert_eq!(config.grant_keys[1].id, "old"); + assert_eq!(config.token_keys[0].id, "current-token"); + assert_eq!(config.token_keys[1].id, "old-token"); + assert_ne!(config.grant_keys[0].key, config.token_keys[0].key); + } + + #[test] + fn malformed_security_configuration_fails_startup() { + for (key, value) in [ + ( + "BUZZ_PUSH_PUBLIC_DELIVERY_URL", + "http://push.example/v1/deliveries/apns", + ), + ( + "BUZZ_PUSH_PUBLIC_DELIVERY_URL", + "https://push.example/v1/deliveries/apns", + ), + ("BUZZ_PUSH_APP_ATTEST_APP_ID", ""), + ("BUZZ_PUSH_ENABLED_PROFILES", "unknown-profile"), + ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), + ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "31536001"), + ("BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS", "0"), + ] { + let mut env = base(); + env.insert(key.into(), value.into()); + assert!(Config::from_map(&env).is_err(), "accepted {key}={value}"); + } + } + + #[test] + fn cross_keyring_id_or_material_reuse_fails_startup() { + for token_keys in [ + format!("current:{}", STANDARD.encode([9; 32])), + format!("other:{}", STANDARD.encode([1; 32])), + ] { + let mut env = base(); + env.insert("BUZZ_PUSH_TOKEN_KEYS".into(), token_keys); + assert!(Config::from_map(&env).is_err()); + } + } + + #[test] + fn malformed_or_empty_keyrings_fail_startup() { + for (variable, value) in [ + ("BUZZ_PUSH_GRANT_KEYS", ""), + ("BUZZ_PUSH_GRANT_KEYS", "missing_separator"), + ("BUZZ_PUSH_GRANT_KEYS", "id:bad-base64"), + ("BUZZ_PUSH_TOKEN_KEYS", ""), + ("BUZZ_PUSH_TOKEN_KEYS", "missing_separator"), + ("BUZZ_PUSH_TOKEN_KEYS", "id:bad-base64"), + ] { + let mut env = base(); + env.insert(variable.into(), value.into()); + assert!(Config::from_map(&env).is_err()); + } + } +} diff --git a/crates/buzz-push-gateway/src/grant.rs b/crates/buzz-push-gateway/src/grant.rs new file mode 100644 index 0000000000..54a29bac3d --- /dev/null +++ b/crates/buzz-push-gateway/src/grant.rs @@ -0,0 +1,240 @@ +//! Authenticated, expiring endpoint grants. The gateway is stateless: relays +//! retain the opaque ciphertext and present it on each delivery attempt. +use std::collections::{HashMap, HashSet}; + +use crate::model::{EndpointGrant, MAX_GRANT_BYTES}; +use aes_gcm::{ + aead::{rand_core::RngCore, Aead, KeyInit, OsRng}, + Aes256Gcm, Nonce, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use thiserror::Error; + +const AAD_PREFIX: &[u8] = b"buzz-stateful-delivery-capability-v1:"; +const MAX_KEY_ID_BYTES: usize = 32; + +#[derive(Clone)] +pub struct GrantKey { + id: String, + cipher: Aes256Gcm, +} + +#[derive(Clone)] +pub struct GrantKeyring { + current: GrantKey, + predecessors: HashMap, +} + +#[derive(Debug, Error)] +pub enum GrantError { + #[error("invalid endpoint grant")] + Invalid, + #[error("duplicate grant key id")] + DuplicateKeyId, + #[error("grant keyring is empty")] + EmptyKeyring, +} + +impl GrantKey { + pub fn new(id: impl Into, key: &[u8]) -> Result { + let id = id.into(); + if id.is_empty() + || id.len() > MAX_KEY_ID_BYTES + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + { + return Err(GrantError::Invalid); + } + Ok(Self { + id, + cipher: Aes256Gcm::new_from_slice(key).map_err(|_| GrantError::Invalid)?, + }) + } + + fn aad(&self) -> Vec { + [AAD_PREFIX, self.id.as_bytes()].concat() + } + + fn seal(&self, grant: &EndpointGrant) -> Result { + let plaintext = serde_json::to_vec(grant).map_err(|_| GrantError::Invalid)?; + let mut nonce = [0u8; 12]; + OsRng.fill_bytes(&mut nonce); + let mut encrypted = nonce.to_vec(); + encrypted.extend( + self.cipher + .encrypt( + Nonce::from_slice(&nonce), + aes_gcm::aead::Payload { + msg: &plaintext, + aad: &self.aad(), + }, + ) + .map_err(|_| GrantError::Invalid)?, + ); + let encoded = format!("{}.{}", self.id, URL_SAFE_NO_PAD.encode(encrypted)); + if encoded.len() > MAX_GRANT_BYTES { + return Err(GrantError::Invalid); + } + Ok(encoded) + } + + fn open(&self, encoded: &str) -> Result { + let (id, payload) = encoded.split_once('.').ok_or(GrantError::Invalid)?; + if id != self.id { + return Err(GrantError::Invalid); + } + let bytes = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| GrantError::Invalid)?; + if bytes.len() < 13 { + return Err(GrantError::Invalid); + } + let plaintext = self + .cipher + .decrypt( + Nonce::from_slice(&bytes[..12]), + aes_gcm::aead::Payload { + msg: &bytes[12..], + aad: &self.aad(), + }, + ) + .map_err(|_| GrantError::Invalid)?; + crate::strict_json::from_slice(&plaintext).map_err(|_| GrantError::Invalid) + } +} + +impl GrantKeyring { + /// Build a keyring ordered current key first, then decrypt-only predecessors. + pub fn new(keys: Vec) -> Result { + let mut keys = keys.into_iter(); + let current = keys.next().ok_or(GrantError::EmptyKeyring)?; + let predecessor_keys: Vec<_> = keys.collect(); + let mut ids = HashSet::with_capacity(predecessor_keys.len() + 1); + if !ids.insert(current.id.as_str()) + || predecessor_keys + .iter() + .any(|key| !ids.insert(key.id.as_str())) + { + return Err(GrantError::DuplicateKeyId); + } + let predecessors = predecessor_keys + .into_iter() + .map(|key| (key.id.clone(), key)) + .collect(); + Ok(Self { + current, + predecessors, + }) + } + + /// Mint with the current key only. + pub fn issue(&self, grant: &EndpointGrant) -> Result { + self.current.seal(grant) + } + + /// Open with the key selected by the authenticated envelope key id. + pub fn open(&self, encoded: &str) -> Result { + if encoded.len() > MAX_GRANT_BYTES { + return Err(GrantError::Invalid); + } + let (id, _) = encoded.split_once('.').ok_or(GrantError::Invalid)?; + if id == self.current.id { + return self.current.open(encoded); + } + self.predecessors + .get(id) + .ok_or(GrantError::Invalid)? + .open(encoded) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::*; + + fn grant() -> EndpointGrant { + EndpointGrant { + v: 1, + delegation_id: uuid::Uuid::nil(), + relay_pubkey: "11".repeat(32), + app_profile: AppProfile::BuzzIosProduction, + endpoint_epoch: 1, + generation: 2, + expires_at: 99, + } + } + + #[test] + fn current_issues_and_predecessor_opens_after_rotation() { + let old = GrantKeyring::new(vec![GrantKey::new("old", &[7; 32]).unwrap()]).unwrap(); + let sealed_old = old.issue(&grant()).unwrap(); + let without_old = + GrantKeyring::new(vec![GrantKey::new("current", &[8; 32]).unwrap()]).unwrap(); + assert!(without_old.open(&sealed_old).is_err()); + let rotated = GrantKeyring::new(vec![ + GrantKey::new("current", &[8; 32]).unwrap(), + GrantKey::new("old", &[7; 32]).unwrap(), + ]) + .unwrap(); + let sealed_current = rotated.issue(&grant()).unwrap(); + assert!(sealed_current.starts_with("current.")); + assert_eq!(rotated.open(&sealed_old).unwrap(), grant()); + assert_eq!(rotated.open(&sealed_current).unwrap(), grant()); + } + + #[test] + fn configured_route_id_is_authenticated_even_when_keys_match() { + let ring = GrantKeyring::new(vec![ + GrantKey::new("current", &[7; 32]).unwrap(), + GrantKey::new("previous", &[7; 32]).unwrap(), + ]) + .unwrap(); + let route_tampered = ring + .issue(&grant()) + .unwrap() + .replacen("current.", "previous.", 1); + assert!(ring.open(&route_tampered).is_err()); + } + + #[test] + fn complete_envelope_length_is_bounded_on_issue_and_open() { + let ring = GrantKeyring::new(vec![GrantKey::new("current", &[7; 32]).unwrap()]).unwrap(); + let mut oversized = grant(); + oversized.relay_pubkey = "a".repeat(MAX_GRANT_BYTES * 2); + assert!(ring.issue(&oversized).is_err()); + + let at_limit = "a".repeat(MAX_GRANT_BYTES); + let over_limit = "a".repeat(MAX_GRANT_BYTES + 1); + assert!(ring.open(&at_limit).is_err()); + assert!(ring.open(&over_limit).is_err()); + } + + #[test] + fn tampering_unknown_ids_and_duplicate_configuration_fail() { + let ring = GrantKeyring::new(vec![GrantKey::new("current", &[7; 32]).unwrap()]).unwrap(); + let sealed = ring.issue(&grant()).unwrap(); + let mut bad = sealed.into_bytes(); + let n = bad.len() - 1; + bad[n] = if bad[n] == b'A' { b'B' } else { b'A' }; + assert!(ring.open(std::str::from_utf8(&bad).unwrap()).is_err()); + let route_tampered = ring + .issue(&grant()) + .unwrap() + .replacen("current.", "other.", 1); + assert!(ring.open(&route_tampered).is_err()); + assert!(ring.open("unknown.AAAA").is_err()); + assert!(matches!( + GrantKeyring::new(vec![ + GrantKey::new("same", &[1; 32]).unwrap(), + GrantKey::new("same", &[2; 32]).unwrap(), + ]), + Err(GrantError::DuplicateKeyId) + )); + assert!(matches!( + GrantKeyring::new(Vec::new()), + Err(GrantError::EmptyKeyring) + )); + } +} diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs new file mode 100644 index 0000000000..0564972c07 --- /dev/null +++ b/crates/buzz-push-gateway/src/http.rs @@ -0,0 +1,776 @@ +//! Stateful installation, delegation, delivery, and health APIs. +use crate::{ + apns::{DeliveryAttempt, DeliveryOutcome, PushTransport}, + app_attest::AppAttestVerifier, + authority::{ + AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation, + }, + grant::GrantKeyring, + model::*, + token::TokenKeyring, +}; +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use nostr::{ + nips::nip98::{verify_auth_header, HttpMethod}, + Event, JsonUtil, Timestamp, +}; +use std::{ + collections::HashSet, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; +use tower::limit::ConcurrencyLimitLayer; +use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer}; + +#[derive(Clone)] +pub struct AppState { + pub grant_keyring: Arc, + pub app_attest: Arc, + pub authority: Arc, + pub token_keyring: Arc, + pub transport: Arc, + pub delivery_url: url::Url, + pub max_grant_lifetime_seconds: i64, + pub max_installation_lifetime_seconds: i64, + pub endpoint_quota_window_seconds: i64, + pub endpoint_quota_max_deliveries: i64, + pub enabled_profiles: HashSet, + pub now: fn() -> i64, + pub accepting: Arc, +} +fn error(status: StatusCode, code: &'static str) -> Response { + (status, Json(ErrorBody { error: code })).into_response() +} +fn valid_endpoint(v: &str) -> bool { + !v.is_empty() + && v.len() <= MAX_ENDPOINT_HEX_BYTES * 2 + && v.len().is_multiple_of(2) + && v.bytes() + .all(|b| b.is_ascii_hexdigit() && (!b.is_ascii_alphabetic() || b.is_ascii_lowercase())) +} +fn valid_relay_pubkey(v: &str) -> bool { + v.len() == 64 + && v.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn auth_event_id(header: &str) -> Option { + let (prefix, encoded) = header.split_once(' ')?; + if prefix != "Nostr" { + return None; + } + Event::from_json(STANDARD.decode(encoded).ok()?) + .ok() + .map(|e| e.id.to_hex()) +} + +fn decode_challenge(value: &str) -> Option<[u8; 32]> { + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(value) + .ok()?; + bytes.try_into().ok() +} +fn authority_error(e: AuthorityError) -> Response { + match e { + AuthorityError::Rejected => error(StatusCode::NOT_FOUND, "not_authorized"), + AuthorityError::Unavailable => { + error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable") + } + } +} +fn endpoint_bytes(endpoint: &str) -> Option> { + valid_endpoint(endpoint) + .then(|| hex::decode(endpoint).ok()) + .flatten() +} +fn endpoint_fingerprint(profile: AppProfile, token: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(b"buzz-apns-endpoint-v1\0"); + h.update(profile.as_str().as_bytes()); + h.update([0]); + h.update(token); + h.finalize().into() +} +fn transcript(domain: &str, value: &T) -> Option { + let body = serde_json::to_string(value).ok()?; + Some(format!("{domain}\n{body}")) +} + +async fn challenge(State(s): State, body: Bytes) -> Response { + let _r: InstallationChallengeRequest = + match crate::strict_json::from_slice::(&body) { + Ok(r) if r.v == WIRE_VERSION => r, + _ => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + let now = (s.now)(); + let expires_at = match now.checked_add(300) { + Some(v) => v, + None => return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"), + }; + let mut value = [0u8; 32]; + if getrandom::fill(&mut value).is_err() { + return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"); + } + let c = Challenge { + id: uuid::Uuid::new_v4(), + value, + expires_at, + }; + if let Err(e) = s.authority.put_challenge(c.clone()).await { + return authority_error(e); + } + ( + StatusCode::OK, + Json(InstallationChallengeResponse { + challenge_id: c.id, + challenge: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(c.value), + expires_at, + }), + ) + .into_response() +} + +#[derive(serde::Serialize)] +struct EnrollTranscript<'a> { + v: u8, + audience: &'static str, + challenge_id: uuid::Uuid, + challenge: &'a str, + key_id: &'a str, + app_profile: AppProfile, + endpoint: &'a str, + endpoint_epoch: i64, + expires_at: i64, +} +async fn enroll(State(s): State, body: Bytes) -> Response { + let r: InstallationEnrollRequest = match crate::strict_json::from_slice(&body) { + Ok(r) => r, + Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + let now = (s.now)(); + let token = match endpoint_bytes(&r.endpoint) { + Some(v) => v, + None => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + if r.v != WIRE_VERSION + || r.endpoint_epoch != 1 + || r.expires_at <= now + || r.expires_at > now.saturating_add(s.max_installation_lifetime_seconds) + || !s.enabled_profiles.contains(&r.app_profile) + { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } + let challenge = match decode_challenge(&r.challenge) { + Some(v) => v, + None => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + let t = EnrollTranscript { + v: r.v, + audience: "https://push.buzz.xyz/v1/installations", + challenge_id: r.challenge_id, + challenge: &r.challenge, + key_id: &r.key_id, + app_profile: r.app_profile, + endpoint: &r.endpoint, + endpoint_epoch: r.endpoint_epoch, + expires_at: r.expires_at, + }; + let signed = match transcript("buzz.push.enroll.v1", &t) { + Some(v) => v, + None => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + let verified = + match s + .app_attest + .verify_attestation(&r.attestation, &r.key_id, signed.as_bytes()) + { + Ok(v) => v, + Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), + }; + if let Err(e) = s + .authority + .consume_challenge(r.challenge_id, challenge, now) + .await + { + return authority_error(e); + } + let ciphertext = match s.token_keyring.seal(&token) { + Ok(v) => v, + Err(_) => return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"), + }; + let id = uuid::Uuid::new_v4(); + let n = NewInstallation { + id, + app_attest_key_id: verified.key_id, + app_attest_public_key: verified.public_key, + assertion_counter: 0, + profile: r.app_profile, + token_ciphertext: ciphertext, + token_fingerprint: endpoint_fingerprint(r.app_profile, &token), + endpoint_epoch: 1, + expires_at: r.expires_at, + }; + if let Err(e) = s.authority.create_installation(n).await { + return authority_error(e); + } + ( + StatusCode::CREATED, + Json(InstallationEnrollResponse { + installation_handle: id, + endpoint_epoch: 1, + expires_at: r.expires_at, + }), + ) + .into_response() +} + +async fn verify_installation_assertion( + s: &AppState, + installation_id: uuid::Uuid, + challenge_id: uuid::Uuid, + challenge_text: &str, + assertion: &str, + domain: &str, + signed: &T, +) -> Result<(), Response> { + let now = (s.now)(); + let challenge = decode_challenge(challenge_text) + .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; + let installation = s + .authority + .installation(installation_id, now) + .await + .map_err(authority_error)?; + let transcript = transcript(domain, signed) + .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; + let verified = s + .app_attest + .verify_assertion( + assertion, + transcript.as_bytes(), + &installation.app_attest_public_key, + installation.assertion_counter, + challenge_text, + challenge_text, + ) + .map_err(|_| error(StatusCode::UNAUTHORIZED, "invalid_attestation"))?; + s.authority + .consume_challenge(challenge_id, challenge, now) + .await + .map_err(authority_error)?; + s.authority + .advance_assertion_counter( + installation_id, + installation.assertion_counter, + verified.counter, + ) + .await + .map_err(authority_error) +} + +#[derive(serde::Serialize)] +struct DelegateTranscript<'a> { + v: u8, + audience: &'static str, + challenge_id: uuid::Uuid, + challenge: &'a str, + installation_handle: uuid::Uuid, + endpoint_epoch: i64, + generation: i64, + relay_pubkey: &'a str, + not_before: i64, + expires_at: i64, +} +async fn delegate(State(s): State, body: Bytes) -> Response { + let r: DelegationRequest = match crate::strict_json::from_slice(&body) { + Ok(r) => r, + Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + let now = (s.now)(); + if r.v != WIRE_VERSION + || !valid_relay_pubkey(&r.relay_pubkey) + || r.endpoint_epoch < 1 + || r.generation < 1 + || r.not_before > now + 300 + || r.expires_at <= r.not_before + || r.expires_at > now + s.max_grant_lifetime_seconds + { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } + let t = DelegateTranscript { + v: r.v, + audience: "https://push.buzz.xyz/v1/delegations", + challenge_id: r.challenge_id, + challenge: &r.challenge, + installation_handle: r.installation_handle, + endpoint_epoch: r.endpoint_epoch, + generation: r.generation, + relay_pubkey: &r.relay_pubkey, + not_before: r.not_before, + expires_at: r.expires_at, + }; + if let Err(e) = verify_installation_assertion( + &s, + r.installation_handle, + r.challenge_id, + &r.challenge, + &r.assertion, + "buzz.push.delegate.v1", + &t, + ) + .await + { + return e; + } + let d = Delegation { + id: uuid::Uuid::new_v4(), + installation_id: r.installation_handle, + relay_pubkey: r.relay_pubkey.clone(), + endpoint_epoch: r.endpoint_epoch, + generation: r.generation, + not_before: r.not_before, + expires_at: r.expires_at, + revoked: false, + }; + if let Err(e) = s.authority.upsert_delegation(d.clone()).await { + return authority_error(e); + } + let g = EndpointGrant { + v: WIRE_VERSION, + delegation_id: d.id, + relay_pubkey: d.relay_pubkey, + app_profile: match s.authority.installation(d.installation_id, now).await { + Ok(i) => i.profile, + Err(e) => return authority_error(e), + }, + endpoint_epoch: d.endpoint_epoch, + generation: d.generation, + expires_at: d.expires_at, + }; + match s.grant_keyring.issue(&g) { + Ok(endpoint_grant) => ( + StatusCode::CREATED, + Json(DelegationResponse { endpoint_grant }), + ) + .into_response(), + Err(_) => error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"), + } +} + +#[derive(serde::Serialize)] +struct RotateTranscript<'a> { + v: u8, + audience: &'static str, + challenge_id: uuid::Uuid, + challenge: &'a str, + installation_handle: uuid::Uuid, + endpoint_epoch: i64, + new_endpoint_epoch: i64, + endpoint: &'a str, +} +async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { + let r: RotateEndpointRequest = match crate::strict_json::from_slice(&body) { + Ok(r) => r, + Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + let token = match endpoint_bytes(&r.endpoint) { + Some(v) => v, + None => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + if r.v != WIRE_VERSION + || r.endpoint_epoch < 1 + || r.new_endpoint_epoch != r.endpoint_epoch.saturating_add(1) + { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } + let installation = match s + .authority + .installation(r.installation_handle, (s.now)()) + .await + { + Ok(i) => i, + Err(e) => return authority_error(e), + }; + let t = RotateTranscript { + v: r.v, + audience: "https://push.buzz.xyz/v1/installations/endpoint", + challenge_id: r.challenge_id, + challenge: &r.challenge, + installation_handle: r.installation_handle, + endpoint_epoch: r.endpoint_epoch, + new_endpoint_epoch: r.new_endpoint_epoch, + endpoint: &r.endpoint, + }; + if let Err(e) = verify_installation_assertion( + &s, + r.installation_handle, + r.challenge_id, + &r.challenge, + &r.assertion, + "buzz.push.rotate-endpoint.v1", + &t, + ) + .await + { + return e; + } + let ciphertext = match s.token_keyring.seal(&token) { + Ok(v) => v, + Err(_) => return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"), + }; + match s + .authority + .rotate_endpoint( + r.installation_handle, + r.endpoint_epoch, + r.new_endpoint_epoch, + ciphertext, + endpoint_fingerprint(installation.profile, &token), + ) + .await + { + Ok(()) => (StatusCode::OK, Json(MutationResponse { status: "rotated" })).into_response(), + Err(e) => authority_error(e), + } +} +#[derive(serde::Serialize)] +struct RevokeDelegationTranscript<'a> { + v: u8, + audience: &'static str, + challenge_id: uuid::Uuid, + challenge: &'a str, + installation_handle: uuid::Uuid, + relay_pubkey: &'a str, + generation: i64, +} +async fn revoke_delegation(State(s): State, body: Bytes) -> Response { + let r: RevokeDelegationRequest = match crate::strict_json::from_slice(&body) { + Ok(r) => r, + Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + if r.v != WIRE_VERSION || !valid_relay_pubkey(&r.relay_pubkey) || r.generation < 1 { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } + let t = RevokeDelegationTranscript { + v: r.v, + audience: "https://push.buzz.xyz/v1/delegations/revoke", + challenge_id: r.challenge_id, + challenge: &r.challenge, + installation_handle: r.installation_handle, + relay_pubkey: &r.relay_pubkey, + generation: r.generation, + }; + if let Err(e) = verify_installation_assertion( + &s, + r.installation_handle, + r.challenge_id, + &r.challenge, + &r.assertion, + "buzz.push.revoke-delegation.v1", + &t, + ) + .await + { + return e; + } + match s + .authority + .revoke_delegation(r.installation_handle, &r.relay_pubkey, r.generation) + .await + { + Ok(()) => (StatusCode::OK, Json(MutationResponse { status: "revoked" })).into_response(), + Err(e) => authority_error(e), + } +} +#[derive(serde::Serialize)] +struct RevokeInstallationTranscript<'a> { + v: u8, + audience: &'static str, + challenge_id: uuid::Uuid, + challenge: &'a str, + installation_handle: uuid::Uuid, + endpoint_epoch: i64, + new_endpoint_epoch: i64, +} +async fn revoke_installation(State(s): State, body: Bytes) -> Response { + let r: RevokeInstallationRequest = match crate::strict_json::from_slice(&body) { + Ok(r) => r, + Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + if r.v != WIRE_VERSION + || r.endpoint_epoch < 1 + || r.new_endpoint_epoch != r.endpoint_epoch.saturating_add(1) + { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } + let t = RevokeInstallationTranscript { + v: r.v, + audience: "https://push.buzz.xyz/v1/installations/revoke", + challenge_id: r.challenge_id, + challenge: &r.challenge, + installation_handle: r.installation_handle, + endpoint_epoch: r.endpoint_epoch, + new_endpoint_epoch: r.new_endpoint_epoch, + }; + if let Err(e) = verify_installation_assertion( + &s, + r.installation_handle, + r.challenge_id, + &r.challenge, + &r.assertion, + "buzz.push.revoke-installation.v1", + &t, + ) + .await + { + return e; + } + match s + .authority + .revoke_installation( + r.installation_handle, + r.endpoint_epoch, + r.new_endpoint_epoch, + ) + .await + { + Ok(()) => (StatusCode::OK, Json(MutationResponse { status: "revoked" })).into_response(), + Err(e) => authority_error(e), + } +} + +async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> Response { + let r: DeliveryRequest = match crate::strict_json::from_slice(&body) { + Ok(x) => x, + Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; + if r.v != WIRE_VERSION { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } + let auth = match headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + { + Some(x) => x, + None => return error(StatusCode::UNAUTHORIZED, "invalid_auth"), + }; + let event_id = match auth_event_id(auth) { + Some(x) => x, + None => return error(StatusCode::UNAUTHORIZED, "invalid_auth"), + }; + let relay = match verify_auth_header( + auth, + &s.delivery_url, + HttpMethod::POST, + Timestamp::now(), + Some(&body), + ) { + Ok(x) => x.to_hex(), + Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_auth"), + }; + let grant = match s.grant_keyring.open(&r.endpoint_grant) { + Ok(x) => x, + Err(_) => return error(StatusCode::NOT_FOUND, "invalid_grant"), + }; + let now = (s.now)(); + if grant.v != WIRE_VERSION + || !valid_relay_pubkey(&grant.relay_pubkey) + || grant.relay_pubkey != relay + || grant.endpoint_epoch < 1 + || grant.generation < 1 + || grant.expires_at < now + || r.expires_at < now + || r.expires_at > grant.expires_at + { + return error(StatusCode::NOT_FOUND, "invalid_grant"); + } + let permit = match s + .authority + .authorize_delivery( + grant.delegation_id, + &relay, + grant.endpoint_epoch, + grant.generation, + &event_id, + r.request_id, + r.expires_at, + s.endpoint_quota_window_seconds, + s.endpoint_quota_max_deliveries, + now, + ) + .await + { + Ok(permit) => { + crate::metrics::record_admission(crate::metrics::Admission::Admitted); + permit + } + Err(AuthorityError::Rejected) => { + crate::metrics::record_admission(crate::metrics::Admission::Rejected); + crate::metrics::record_delivery_error("invalid_grant"); + return error(StatusCode::NOT_FOUND, "invalid_grant"); + } + Err(AuthorityError::Unavailable) => { + crate::metrics::record_admission(crate::metrics::Admission::Unavailable); + crate::metrics::record_delivery_error("temporarily_unavailable"); + return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"); + } + }; + if permit.authority.profile != grant.app_profile { + crate::metrics::record_delivery_error("profile_mismatch"); + let _ = s + .authority + .finish_delivery(permit, DeliveryDisposition::Terminal) + .await; + return error(StatusCode::NOT_FOUND, "invalid_grant"); + } + let profile = permit.authority.profile; + let endpoint = match s.token_keyring.open(&permit.authority.token_ciphertext) { + Ok(token) => hex::encode(token), + Err(_) => { + crate::metrics::record_delivery_error("token_custody"); + let _ = s + .authority + .finish_delivery(permit, DeliveryDisposition::Retryable) + .await; + return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"); + } + }; + let attempt = DeliveryAttempt { + request_id: r.request_id, + expires_at: r.expires_at, + }; + let transport = Arc::clone(&s.transport); + let authority_store = Arc::clone(&s.authority); + // Admission already committed, so cancellation cannot undo either replay + // fence. The detached task completes disposition bookkeeping. + let delivery = tokio::spawn(async move { + let started = std::time::Instant::now(); + let mut outcome = transport.send(attempt, profile, &endpoint).await; + if outcome == DeliveryOutcome::RefreshCredential { + crate::metrics::record_credential_refresh(); + transport.refresh_credential(); + outcome = transport.send(attempt, profile, &endpoint).await; + } + crate::metrics::record_apns_delivery(outcome, started.elapsed().as_secs_f64()); + let disposition = match outcome { + DeliveryOutcome::Retry { .. } + | DeliveryOutcome::ConfigurationFault + | DeliveryOutcome::RefreshCredential => DeliveryDisposition::Retryable, + DeliveryOutcome::Accepted + | DeliveryOutcome::InvalidEndpoint { .. } + | DeliveryOutcome::PermanentRequestFault => DeliveryDisposition::Terminal, + }; + authority_store + .finish_delivery(permit, disposition) + .await + .map(|()| outcome) + }); + let outcome = match delivery.await { + Ok(Ok(outcome)) => outcome, + _ => { + crate::metrics::record_delivery_error("finish_failed"); + return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"); + } + }; + match outcome { + DeliveryOutcome::Accepted => { + (StatusCode::OK, Json(DeliveryResponse::Accepted)).into_response() + } + DeliveryOutcome::InvalidEndpoint { unregistered_at } => ( + StatusCode::GONE, + Json(DeliveryResponse::InvalidEndpoint { + generation: grant.generation, + invalid_at: unregistered_at, + }), + ) + .into_response(), + DeliveryOutcome::Retry { + retry_after_seconds, + } => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(DeliveryResponse::Retry { + retry_after_seconds, + }), + ) + .into_response(), + DeliveryOutcome::ConfigurationFault | DeliveryOutcome::RefreshCredential => { + error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault") + } + DeliveryOutcome::PermanentRequestFault => error(StatusCode::BAD_REQUEST, "invalid_request"), + } +} +async fn live() -> Json { + Json(serde_json::json!({"status":"alive"})) +} +async fn ready(State(s): State) -> Response { + if !s.accepting.load(Ordering::Relaxed) { + crate::metrics::record_readiness_failure(crate::metrics::ReadinessFailure::NotAccepting); + return error(StatusCode::SERVICE_UNAVAILABLE, "not_ready"); + } + if s.authority.ready().await.is_err() { + crate::metrics::record_readiness_failure(crate::metrics::ReadinessFailure::Authority); + return error(StatusCode::SERVICE_UNAVAILABLE, "not_ready"); + } + Json(serde_json::json!({"status":"ready"})).into_response() +} +pub fn router(state: AppState) -> (Router, Router) { + router_with_metrics(state, None) +} + +/// Build the public and private routers. When `metrics_handle` is provided, the +/// private health router additionally serves `GET /metrics` in Prometheus text +/// format. Metrics live only on the private router, never on the public port. +pub fn router_with_metrics( + state: AppState, + metrics_handle: Option, +) -> (Router, Router) { + let public = Router::new() + .route("/v1/installations/challenges", post(challenge)) + .route("/v1/installations", post(enroll)) + .route("/v1/delegations", post(delegate)) + .route("/v1/delegations/revoke", post(revoke_delegation)) + .route("/v1/installations/endpoint", post(rotate_endpoint)) + .route("/v1/installations/revoke", post(revoke_installation)) + .route("/v1/deliveries/apns", post(deliver)) + .with_state(state.clone()) + .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES)) + .layer(ConcurrencyLimitLayer::new(256)) + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + Duration::from_secs(20), + )); + let mut health = Router::new() + .route("/_liveness", get(live)) + .route("/_readiness", get(ready)) + .with_state(state); + if let Some(handle) = metrics_handle { + health = health.route( + "/metrics", + get(move || { + let handle = handle.clone(); + async move { + handle.run_upkeep(); + ( + [( + axum::http::header::CONTENT_TYPE, + "text/plain; version=0.0.4", + )], + handle.render(), + ) + } + }), + ); + } + (public, health) +} diff --git a/crates/buzz-push-gateway/src/lib.rs b/crates/buzz-push-gateway/src/lib.rs new file mode 100644 index 0000000000..563d725db9 --- /dev/null +++ b/crates/buzz-push-gateway/src/lib.rs @@ -0,0 +1,13 @@ +//! Stateful, capability-gated APNs last hop for NIP-PL. +pub mod apns; +pub mod app_attest; +pub mod authority; +pub mod config; +pub mod grant; +pub mod http; +pub mod metrics; +pub mod model; +pub mod postgres; +pub(crate) mod strict_json; +pub mod token; +pub use http::{router, router_with_metrics, AppState}; diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs new file mode 100644 index 0000000000..55e1853d3b --- /dev/null +++ b/crates/buzz-push-gateway/src/main.rs @@ -0,0 +1,143 @@ +use buzz_push_gateway::{ + apns::ApnsTransport, + app_attest::AppAttestVerifier, + authority::AuthorityStore, + config::Config, + grant::{GrantKey, GrantKeyring}, + postgres::PostgresAuthorityStore, + router_with_metrics, + token::{TokenKey, TokenKeyring}, + AppState, +}; +use std::{ + fs, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; +use tracing_subscriber::EnvFilter; +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .json() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + if std::env::args().nth(1).as_deref() == Some("--migrate-only") { + let database_url = std::env::var("DATABASE_URL")?; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await?; + let runtime_role = std::env::var("BUZZ_PUSH_RUNTIME_DATABASE_ROLE")?; + PostgresAuthorityStore::apply_migrations_and_grants(&pool, &runtime_role).await?; + return Ok(()); + } + let c = Config::from_env()?; + let metrics_handle = buzz_push_gateway::metrics::install()?; + let transport = Arc::new(ApnsTransport::token( + &fs::read(&c.apns_key_path)?, + &c.apns_key_id, + &c.apns_team_id, + c.apns_topic, + )?); + let grant_keyring = GrantKeyring::new( + c.grant_keys + .iter() + .map(|key| GrantKey::new(&key.id, &key.key)) + .collect::>()?, + )?; + let token_keyring = TokenKeyring::new( + c.token_keys + .iter() + .map(|key| TokenKey::new(&key.id, &key.key)) + .collect::>()?, + )?; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(20) + .connect(&c.database_url) + .await?; + let authority = Arc::new(PostgresAuthorityStore::new(pool)); + authority + .reap_expired(chrono::Utc::now().timestamp()) + .await?; + let reaper_authority = Arc::clone(&authority); + let reaper = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(300)); + interval.tick().await; + loop { + interval.tick().await; + if reaper_authority + .reap_expired(chrono::Utc::now().timestamp()) + .await + .is_err() + { + buzz_push_gateway::metrics::record_reaper_failure(); + tracing::warn!("push gateway retention reaper failed"); + } + } + }); + let app_attest = Arc::new(AppAttestVerifier::new( + c.app_attest_app_id, + fs::read(&c.app_attest_root_cert_path)?, + )?); + let accepting = Arc::new(AtomicBool::new(true)); + let (public, health) = router_with_metrics( + AppState { + grant_keyring: Arc::new(grant_keyring), + app_attest, + authority, + token_keyring: Arc::new(token_keyring), + transport, + delivery_url: c.public_delivery_url, + max_grant_lifetime_seconds: c.max_grant_lifetime_seconds, + max_installation_lifetime_seconds: c.max_installation_lifetime_seconds, + endpoint_quota_window_seconds: c.endpoint_quota_window_seconds, + endpoint_quota_max_deliveries: c.endpoint_quota_max_deliveries, + enabled_profiles: c.enabled_profiles, + now: || chrono::Utc::now().timestamp(), + accepting: accepting.clone(), + }, + Some(metrics_handle), + ); + let pl = tokio::net::TcpListener::bind(c.bind_addr).await?; + let hl = tokio::net::TcpListener::bind(c.health_addr).await?; + let (ptx, prx) = tokio::sync::watch::channel(false); + let (htx, hrx) = tokio::sync::watch::channel(false); + let p = tokio::spawn(async move { + axum::serve(pl, public) + .with_graceful_shutdown(async move { + let mut rx = prx; + let _ = rx.changed().await; + }) + .await + }); + let h = tokio::spawn(async move { + axum::serve(hl, health) + .with_graceful_shutdown(async move { + let mut rx = hrx; + let _ = rx.changed().await; + }) + .await + }); + shutdown_signal().await?; + accepting.store(false, Ordering::SeqCst); + let _ = ptx.send(true); + let _ = tokio::time::timeout(std::time::Duration::from_secs(30), p).await; + let _ = htx.send(true); + let _ = h.await; + reaper.abort(); + Ok(()) +} +async fn shutdown_signal() -> std::io::Result<()> { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = signal(SignalKind::terminate())?; + tokio::select! {r=tokio::signal::ctrl_c()=>r,_=term.recv()=>Ok(())} + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await + } +} diff --git a/crates/buzz-push-gateway/src/metrics.rs b/crates/buzz-push-gateway/src/metrics.rs new file mode 100644 index 0000000000..f40c126c79 --- /dev/null +++ b/crates/buzz-push-gateway/src/metrics.rs @@ -0,0 +1,207 @@ +//! Sanitized, bounded-cardinality Prometheus metrics for the push gateway. +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────┐ +//! │ metrics-rs facade (metrics::counter!, histogram!) │ +//! │ ↓ │ +//! │ PrometheusBuilder::install_recorder() → PrometheusHandle │ +//! │ ↓ │ +//! │ GET /metrics on the PRIVATE health router (port 8081) │ +//! └──────────────────────────────────────────────────────────┘ +//! ``` +//! +//! Every label value emitted here is a compile-time `&'static str` drawn from a +//! closed set (the [`DeliveryOutcome`] variants, the gateway's fixed error +//! codes, and the handler stages). No endpoint, device token, relay pubkey, +//! request id, or any other request-scoped identifier is ever used as a label, +//! so metric cardinality is structurally bounded regardless of traffic. + +use crate::apns::DeliveryOutcome; +use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder, PrometheusHandle}; + +/// Seconds-scale buckets for the APNs send round-trip histogram. +const APNS_LATENCY_BUCKETS_S: [f64; 11] = [ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 15.0, +]; + +/// Install the global metrics recorder and return the render handle. +/// +/// Unlike the relay's exporter, this installs **no** HTTP listener: rendering is +/// served from the private health router so metrics never share the public port. +/// Must be called at most once per process, from within a Tokio runtime. +pub fn install() -> Result { + let handle = PrometheusBuilder::new() + .set_buckets_for_metric( + Matcher::Full("push_gateway_apns_delivery_seconds".to_owned()), + &APNS_LATENCY_BUCKETS_S, + )? + .install_recorder()?; + Ok(handle) +} + +/// Stable metric label for each sanitized delivery outcome. The mapping is total +/// over the closed [`DeliveryOutcome`] enum, so the `outcome` label can only take +/// these six values. +fn outcome_label(outcome: DeliveryOutcome) -> &'static str { + match outcome { + DeliveryOutcome::Accepted => "accepted", + DeliveryOutcome::InvalidEndpoint { .. } => "invalid_endpoint", + DeliveryOutcome::Retry { .. } => "retry", + DeliveryOutcome::RefreshCredential => "refresh_credential", + DeliveryOutcome::ConfigurationFault => "configuration_fault", + DeliveryOutcome::PermanentRequestFault => "permanent_request_fault", + } +} + +/// Record the terminal APNs outcome and its send round-trip latency. +pub fn record_apns_delivery(outcome: DeliveryOutcome, seconds: f64) { + metrics::counter!("push_gateway_apns_deliveries_total", "outcome" => outcome_label(outcome)) + .increment(1); + metrics::histogram!("push_gateway_apns_delivery_seconds").record(seconds); +} + +/// Record that a cached provider credential was refreshed after APNs reported expiry. +pub fn record_credential_refresh() { + metrics::counter!("push_gateway_apns_credential_refreshes_total").increment(1); +} + +/// Delivery-admission result at the `authorize_delivery` seam. +#[derive(Debug, Clone, Copy)] +pub enum Admission { + /// A delivery permit was issued. + Admitted, + /// The replay/quota/authority fence rejected the request. + Rejected, + /// The authority store was transiently unavailable. + Unavailable, +} + +/// Record the outcome of a delivery-admission attempt. +pub fn record_admission(result: Admission) { + let label = match result { + Admission::Admitted => "admitted", + Admission::Rejected => "rejected", + Admission::Unavailable => "unavailable", + }; + metrics::counter!("push_gateway_admissions_total", "result" => label).increment(1); +} + +/// Record a delivery-path error, tagged by the static failure class. This +/// counter covers only the `/v1/deliveries/apns` handler's post-admission exit +/// classes (admission rejection/unavailability, profile mismatch, token-custody +/// open failure, and detached finish/join failure); pre-admission request/auth/ +/// attestation validation on the enrollment and delegation handlers is not +/// counted here. `class` is always a compile-time constant. +pub fn record_delivery_error(class: &'static str) { + metrics::counter!("push_gateway_delivery_errors_total", "class" => class).increment(1); +} + +/// Record a retention-reaper sweep failure. +pub fn record_reaper_failure() { + metrics::counter!("push_gateway_reaper_failures_total").increment(1); +} + +/// Why a readiness probe reported not-ready. +#[derive(Debug, Clone, Copy)] +pub enum ReadinessFailure { + /// The process is draining and no longer accepting traffic. + NotAccepting, + /// The authority store readiness check failed. + Authority, +} + +/// Record a readiness-probe failure by cause. +pub fn record_readiness_failure(cause: ReadinessFailure) { + let label = match cause { + ReadinessFailure::NotAccepting => "not_accepting", + ReadinessFailure::Authority => "authority", + }; + metrics::counter!("push_gateway_readiness_failures_total", "cause" => label).increment(1); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn outcome_label_covers_every_variant_with_static_strings() { + // Exhaustive over the closed enum; each arm is a compile-time constant, + // so the `outcome` label is structurally bounded to these six values. + for (outcome, expected) in [ + (DeliveryOutcome::Accepted, "accepted"), + ( + DeliveryOutcome::InvalidEndpoint { + unregistered_at: Some(7), + }, + "invalid_endpoint", + ), + ( + DeliveryOutcome::Retry { + retry_after_seconds: Some(30), + }, + "retry", + ), + (DeliveryOutcome::RefreshCredential, "refresh_credential"), + (DeliveryOutcome::ConfigurationFault, "configuration_fault"), + ( + DeliveryOutcome::PermanentRequestFault, + "permanent_request_fault", + ), + ] { + assert_eq!(outcome_label(outcome), expected); + } + } + + // The global metrics recorder can be installed only once per process, so a + // single test owns the install and exercises every helper end-to-end, + // asserting the rendered exposition is sanitized and bounded-cardinality. + #[test] + fn recorder_renders_sanitized_bounded_series() { + let handle = install().expect("recorder installs exactly once per test process"); + + record_apns_delivery(DeliveryOutcome::Accepted, 0.012); + record_apns_delivery( + DeliveryOutcome::InvalidEndpoint { + unregistered_at: None, + }, + 0.030, + ); + record_credential_refresh(); + record_admission(Admission::Admitted); + record_admission(Admission::Rejected); + record_admission(Admission::Unavailable); + record_delivery_error("invalid_grant"); + record_delivery_error("finish_failed"); + record_reaper_failure(); + record_readiness_failure(ReadinessFailure::NotAccepting); + record_readiness_failure(ReadinessFailure::Authority); + + let rendered = handle.render(); + + // All expected series are present. + for needle in [ + "push_gateway_apns_deliveries_total", + "push_gateway_apns_delivery_seconds", + "push_gateway_apns_credential_refreshes_total", + "push_gateway_admissions_total", + "push_gateway_delivery_errors_total", + "push_gateway_reaper_failures_total", + "push_gateway_readiness_failures_total", + ] { + assert!(rendered.contains(needle), "missing series {needle}"); + } + // Labels are the closed static sets only. + for needle in [ + "outcome=\"accepted\"", + "outcome=\"invalid_endpoint\"", + "result=\"admitted\"", + "result=\"rejected\"", + "result=\"unavailable\"", + "class=\"invalid_grant\"", + "cause=\"not_accepting\"", + "cause=\"authority\"", + ] { + assert!(rendered.contains(needle), "missing label {needle}"); + } + } +} diff --git a/crates/buzz-push-gateway/src/model.rs b/crates/buzz-push-gateway/src/model.rs new file mode 100644 index 0000000000..23f8015fe0 --- /dev/null +++ b/crates/buzz-push-gateway/src/model.rs @@ -0,0 +1,170 @@ +//! Closed wire types for the stateful gateway. + +use serde::{Deserialize, Serialize}; + +pub const MAX_REQUEST_BYTES: usize = 8 * 1024; +pub const MAX_GRANT_BYTES: usize = 4096; +pub const MAX_ENDPOINT_HEX_BYTES: usize = 512; +pub const APNS_RECONNECT_PAYLOAD: &[u8] = + br#"{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}"#; +pub const WIRE_VERSION: u8 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AppProfile { + BuzzIosProduction, + BuzzIosSandbox, +} +impl AppProfile { + pub const fn as_str(self) -> &'static str { + match self { + Self::BuzzIosProduction => "buzz-ios-production", + Self::BuzzIosSandbox => "buzz-ios-sandbox", + } + } +} + +/// Relay request. It deliberately has no application-payload field: +/// the gateway emits one compiled-in APNs reconnect payload for every delivery. +/// `endpoint_grant` is opaque authenticated ciphertext minted by the gateway +/// sealing key and persisted with the relay-owned lease. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeliveryRequest { + pub v: u8, + pub endpoint_grant: String, + pub request_id: uuid::Uuid, + pub expires_at: i64, +} + +/// Opaque delivery capability plaintext. It contains no APNs token: the random +/// delegation id resolves through durable authority state, while the remaining +/// fields are authenticated fences that make stale or cross-relay use fail. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EndpointGrant { + pub v: u8, + pub delegation_id: uuid::Uuid, + pub relay_pubkey: String, + pub app_profile: AppProfile, + pub endpoint_epoch: i64, + pub generation: i64, + pub expires_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstallationChallengeRequest { + pub v: u8, +} + +#[derive(Debug, Clone, Serialize)] +pub struct InstallationChallengeResponse { + pub challenge_id: uuid::Uuid, + pub challenge: String, + pub expires_at: i64, +} + +/// Direct app enrollment. `attestation` is Apple's CBOR object and `key_id` is +/// the App Attest key identifier, both base64 encoded. The attested key is the +/// installation authority; no second application signing key is introduced. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstallationEnrollRequest { + pub v: u8, + pub challenge_id: uuid::Uuid, + pub challenge: String, + pub key_id: String, + pub attestation: String, + pub app_profile: AppProfile, + pub endpoint: String, + pub endpoint_epoch: i64, + pub expires_at: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct InstallationEnrollResponse { + pub installation_handle: uuid::Uuid, + pub endpoint_epoch: i64, + pub expires_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DelegationRequest { + pub v: u8, + pub challenge_id: uuid::Uuid, + pub challenge: String, + pub installation_handle: uuid::Uuid, + pub endpoint_epoch: i64, + pub generation: i64, + pub relay_pubkey: String, + pub not_before: i64, + pub expires_at: i64, + pub assertion: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DelegationResponse { + pub endpoint_grant: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RotateEndpointRequest { + pub v: u8, + pub challenge_id: uuid::Uuid, + pub challenge: String, + pub installation_handle: uuid::Uuid, + pub endpoint_epoch: i64, + pub new_endpoint_epoch: i64, + pub endpoint: String, + pub assertion: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RevokeDelegationRequest { + pub v: u8, + pub challenge_id: uuid::Uuid, + pub challenge: String, + pub installation_handle: uuid::Uuid, + pub relay_pubkey: String, + pub generation: i64, + pub assertion: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RevokeInstallationRequest { + pub v: u8, + pub challenge_id: uuid::Uuid, + pub challenge: String, + pub installation_handle: uuid::Uuid, + pub endpoint_epoch: i64, + pub new_endpoint_epoch: i64, + pub assertion: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MutationResponse { + pub status: &'static str, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "status", deny_unknown_fields)] +pub enum DeliveryResponse { + Accepted, + InvalidEndpoint { + generation: i64, + invalid_at: Option, + }, + Retry { + retry_after_seconds: Option, + }, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ErrorBody { + pub error: &'static str, +} diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs new file mode 100644 index 0000000000..bd69ec2564 --- /dev/null +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -0,0 +1,952 @@ +//! PostgreSQL authority store. Mutations use row locks and compare-and-swap +//! predicates so counters, epochs, and generation tombstones only move forward. +use crate::authority::*; +use crate::model::AppProfile; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::{AssertSqlSafe, PgPool, Row}; +use uuid::Uuid; + +#[derive(Clone)] +pub struct PostgresAuthorityStore { + pool: PgPool, +} +impl PostgresAuthorityStore { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub async fn apply_migrations_and_grants( + pool: &PgPool, + runtime_role: &str, + ) -> Result<(), Box> { + sqlx::migrate!("./migrations").run(pool).await?; + if runtime_role.is_empty() + || runtime_role.len() > 63 + || !runtime_role + .bytes() + .enumerate() + .all(|(i, b)| b == b'_' || b.is_ascii_alphabetic() || (i > 0 && b.is_ascii_digit())) + { + return Err("runtime database role must be a PostgreSQL identifier".into()); + } + let database: String = sqlx::query_scalar("SELECT current_database()") + .fetch_one(pool) + .await?; + let quote_ident = |value: &str| format!("\"{}\"", value.replace('"', "\"\"")); + let role = quote_ident(runtime_role); + let database = quote_ident(&database); + let grants = format!( + "REVOKE CREATE ON DATABASE {database} FROM {role}; + REVOKE CREATE ON SCHEMA public FROM PUBLIC; + REVOKE CREATE ON SCHEMA public FROM {role}; + GRANT CONNECT ON DATABASE {database} TO {role}; + GRANT USAGE ON SCHEMA public TO {role}; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE + push_gateway_challenges, + push_gateway_installations, + push_gateway_delegations, + push_gateway_endpoint_quotas, + push_gateway_delivery_auth_replays, + push_gateway_delivery_request_replays + TO {role};" + ); + sqlx::raw_sql(AssertSqlSafe(grants)).execute(pool).await?; + Ok(()) + } +} +fn at(ts: i64) -> Result, AuthorityError> { + DateTime::from_timestamp(ts, 0).ok_or(AuthorityError::Rejected) +} +fn ts(v: DateTime) -> i64 { + v.timestamp() +} +fn profile(v: &str) -> Result { + match v { + "buzz-ios-production" => Ok(AppProfile::BuzzIosProduction), + "buzz-ios-sandbox" => Ok(AppProfile::BuzzIosSandbox), + _ => Err(AuthorityError::Unavailable), + } +} +fn db(_: sqlx::Error) -> AuthorityError { + AuthorityError::Unavailable +} +fn bytes32(v: Vec) -> Result<[u8; 32], AuthorityError> { + v.try_into().map_err(|_| AuthorityError::Unavailable) +} + +#[async_trait] +impl AuthorityStore for PostgresAuthorityStore { + async fn ready(&self) -> Result<(), AuthorityError> { + const TABLES: [&str; 6] = [ + "push_gateway_challenges", + "push_gateway_installations", + "push_gateway_delegations", + "push_gateway_endpoint_quotas", + "push_gateway_delivery_auth_replays", + "push_gateway_delivery_request_replays", + ]; + let mut tx = self.pool.begin().await.map_err(db)?; + for table in TABLES { + let ready: bool = sqlx::query_scalar( + "SELECT to_regclass($1) IS NOT NULL + AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'SELECT'), false) + AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'INSERT'), false) + AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'UPDATE'), false) + AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'DELETE'), false)", + ) + .bind(format!("public.{table}")) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + if !ready { + return Err(AuthorityError::Unavailable); + } + } + let least_privilege: bool = sqlx::query_scalar( + "SELECT has_database_privilege(current_user, current_database(), 'CONNECT') + AND NOT has_database_privilege(current_user, current_database(), 'CREATE') + AND NOT has_schema_privilege(current_user, 'public', 'CREATE')", + ) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + if !least_privilege { + return Err(AuthorityError::Unavailable); + } + tx.rollback().await.map_err(db) + } + + async fn put_challenge(&self, c: Challenge) -> Result<(), AuthorityError> { + use sha2::{Digest, Sha256}; + sqlx::query( + "INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at) VALUES($1,$2,$3)", + ) + .bind(c.id) + .bind(Sha256::digest(c.value).to_vec()) + .bind(at(c.expires_at)?) + .execute(&self.pool) + .await + .map_err(db)?; + Ok(()) + } + async fn consume_challenge( + &self, + id: Uuid, + value: [u8; 32], + now: i64, + ) -> Result<(), AuthorityError> { + use sha2::{Digest, Sha256}; + let result = sqlx::query("DELETE FROM push_gateway_challenges WHERE id=$1 AND challenge_hash=$2 AND expires_at >= $3") + .bind(id).bind(Sha256::digest(value).to_vec()).bind(at(now)?).execute(&self.pool).await.map_err(db)?; + if result.rows_affected() != 1 { + return Err(AuthorityError::Rejected); + } + Ok(()) + } + async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> { + let result = sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING") + .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&self.pool).await.map_err(db)?; + if result.rows_affected() != 1 { + return Err(AuthorityError::Rejected); + } + Ok(()) + } + async fn installation(&self, id: Uuid, now: i64) -> Result { + let r = sqlx::query("SELECT * FROM push_gateway_installations WHERE id=$1 AND revoked_at IS NULL AND expires_at >= $2") + .bind(id).bind(at(now)?).fetch_optional(&self.pool).await.map_err(db)?.ok_or(AuthorityError::Rejected)?; + Ok(Installation { + id, + app_attest_key_id: r.try_get("app_attest_key_id").map_err(db)?, + app_attest_public_key: r.try_get("app_attest_public_key").map_err(db)?, + assertion_counter: u32::try_from(r.try_get::("assertion_counter").map_err(db)?) + .map_err(|_| AuthorityError::Unavailable)?, + profile: profile(r.try_get("app_profile").map_err(db)?)?, + token_ciphertext: r.try_get("token_ciphertext").map_err(db)?, + token_fingerprint: bytes32(r.try_get("token_fingerprint").map_err(db)?)?, + endpoint_epoch: r.try_get("endpoint_epoch").map_err(db)?, + expires_at: ts(r.try_get("expires_at").map_err(db)?), + revoked: false, + }) + } + async fn advance_assertion_counter( + &self, + id: Uuid, + previous: u32, + next: u32, + ) -> Result<(), AuthorityError> { + if next <= previous { + return Err(AuthorityError::Rejected); + } + let result=sqlx::query("UPDATE push_gateway_installations SET assertion_counter=$3,updated_at=now() WHERE id=$1 AND assertion_counter=$2 AND revoked_at IS NULL") + .bind(id).bind(i64::from(previous)).bind(i64::from(next)).execute(&self.pool).await.map_err(db)?; + if result.rows_affected() != 1 { + return Err(AuthorityError::Rejected); + } + Ok(()) + } + async fn upsert_delegation(&self, d: Delegation) -> Result<(), AuthorityError> { + let mut tx = self.pool.begin().await.map_err(db)?; + let i=sqlx::query("SELECT endpoint_epoch,expires_at,revoked_at FROM push_gateway_installations WHERE id=$1 FOR UPDATE").bind(d.installation_id).fetch_optional(&mut *tx).await.map_err(db)?.ok_or(AuthorityError::Rejected)?; + if i.try_get::>, _>("revoked_at") + .map_err(db)? + .is_some() + || i.try_get::("endpoint_epoch").map_err(db)? != d.endpoint_epoch + || at(d.expires_at)? > i.try_get::, _>("expires_at").map_err(db)? + { + return Err(AuthorityError::Rejected); + } + let relay = hex::decode(&d.relay_pubkey).map_err(|_| AuthorityError::Rejected)?; + let result=sqlx::query("INSERT INTO push_gateway_delegations(id,installation_id,relay_pubkey,endpoint_epoch,generation,not_before,expires_at,revoked_at) VALUES($1,$2,$3,$4,$5,$6,$7,NULL) ON CONFLICT(installation_id,relay_pubkey) DO UPDATE SET id=EXCLUDED.id,endpoint_epoch=EXCLUDED.endpoint_epoch,generation=EXCLUDED.generation,not_before=EXCLUDED.not_before,expires_at=EXCLUDED.expires_at,revoked_at=NULL,updated_at=now() WHERE EXCLUDED.generation > push_gateway_delegations.generation") + .bind(d.id).bind(d.installation_id).bind(relay).bind(d.endpoint_epoch).bind(d.generation).bind(at(d.not_before)?).bind(at(d.expires_at)?).execute(&mut *tx).await.map_err(db)?; + if result.rows_affected() != 1 { + return Err(AuthorityError::Rejected); + } + tx.commit().await.map_err(db)?; + Ok(()) + } + async fn rotate_endpoint( + &self, + id: Uuid, + expected: i64, + new: i64, + ciphertext: Vec, + fingerprint: [u8; 32], + ) -> Result<(), AuthorityError> { + if new != expected.checked_add(1).ok_or(AuthorityError::Rejected)? { + return Err(AuthorityError::Rejected); + } + let result=sqlx::query("UPDATE push_gateway_installations SET endpoint_epoch=$3,token_ciphertext=$4,token_fingerprint=$5,updated_at=now() WHERE id=$1 AND endpoint_epoch=$2 AND revoked_at IS NULL").bind(id).bind(expected).bind(new).bind(ciphertext).bind(fingerprint.to_vec()).execute(&self.pool).await.map_err(db)?; + if result.rows_affected() != 1 { + return Err(AuthorityError::Rejected); + } + Ok(()) + } + async fn revoke_delegation( + &self, + id: Uuid, + relay: &str, + generation: i64, + ) -> Result<(), AuthorityError> { + let relay = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?; + let result=sqlx::query("UPDATE push_gateway_delegations SET generation=$3,revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation<$3").bind(id).bind(relay).bind(generation).execute(&self.pool).await.map_err(db)?; + if result.rows_affected() != 1 { + return Err(AuthorityError::Rejected); + } + Ok(()) + } + async fn revoke_installation( + &self, + id: Uuid, + expected: i64, + new: i64, + ) -> Result<(), AuthorityError> { + if new != expected.checked_add(1).ok_or(AuthorityError::Rejected)? { + return Err(AuthorityError::Rejected); + } + let result=sqlx::query("UPDATE push_gateway_installations SET endpoint_epoch=$3,revoked_at=now(),updated_at=now() WHERE id=$1 AND endpoint_epoch=$2 AND revoked_at IS NULL").bind(id).bind(expected).bind(new).execute(&self.pool).await.map_err(db)?; + if result.rows_affected() != 1 { + return Err(AuthorityError::Rejected); + } + Ok(()) + } + async fn authorize_delivery( + &self, + did: Uuid, + relay: &str, + epoch: i64, + generation: i64, + event_id: &str, + request_id: Uuid, + request_expires_at: i64, + quota_window_seconds: i64, + quota_max_deliveries: i64, + now: i64, + ) -> Result { + let relay_bytes = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?; + let event_bytes = hex::decode(event_id).map_err(|_| AuthorityError::Rejected)?; + if event_bytes.len() != 32 { + return Err(AuthorityError::Rejected); + } + let mut tx = self.pool.begin().await.map_err(db)?; + // Every authority mutation locks installation before delegation. Keep + // this order here to avoid delivery-vs-refresh deadlocks. + let i = sqlx::query( + "SELECT app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at,revoked_at + FROM push_gateway_installations + WHERE id=(SELECT installation_id FROM push_gateway_delegations WHERE id=$1) + FOR UPDATE", + ) + .bind(did) + .fetch_optional(&mut *tx) + .await + .map_err(db)? + .ok_or(AuthorityError::Rejected)?; + if i.try_get::>, _>("revoked_at") + .map_err(db)? + .is_some() + || i.try_get::("endpoint_epoch").map_err(db)? != epoch + || i.try_get::, _>("expires_at").map_err(db)? < at(now)? + { + return Err(AuthorityError::Rejected); + } + let d = sqlx::query( + "SELECT installation_id,expires_at FROM push_gateway_delegations + WHERE id=$1 AND relay_pubkey=$2 AND endpoint_epoch=$3 AND generation=$4 + AND revoked_at IS NULL AND not_before<=$5 AND expires_at>=$5 + FOR UPDATE", + ) + .bind(did) + .bind(&relay_bytes) + .bind(epoch) + .bind(generation) + .bind(at(now)?) + .fetch_optional(&mut *tx) + .await + .map_err(db)? + .ok_or(AuthorityError::Rejected)?; + let installation_id: Uuid = d.try_get("installation_id").map_err(db)?; + let authority = DeliveryAuthority { + delegation_id: did, + installation_id, + relay_pubkey: relay.to_owned(), + profile: profile(i.try_get("app_profile").map_err(db)?)?, + token_ciphertext: i.try_get("token_ciphertext").map_err(db)?, + endpoint_epoch: epoch, + generation, + expires_at: ts(d.try_get("expires_at").map_err(db)?), + }; + if request_expires_at < now || request_expires_at > authority.expires_at { + return Err(AuthorityError::Rejected); + } + if quota_window_seconds < 1 || quota_max_deliveries < 1 { + return Err(AuthorityError::Unavailable); + } + let fingerprint: Vec = i.try_get("token_fingerprint").map_err(db)?; + let quota = sqlx::query("INSERT INTO push_gateway_endpoint_quotas(token_fingerprint,window_started_at,admitted) VALUES($1,$2,1) ON CONFLICT(token_fingerprint) DO UPDATE SET window_started_at=CASE WHEN push_gateway_endpoint_quotas.window_started_at <= $2 - make_interval(secs => $3::double precision) THEN $2 ELSE push_gateway_endpoint_quotas.window_started_at END, admitted=CASE WHEN push_gateway_endpoint_quotas.window_started_at <= $2 - make_interval(secs => $3::double precision) THEN 1 ELSE push_gateway_endpoint_quotas.admitted + 1 END, updated_at=now() WHERE push_gateway_endpoint_quotas.window_started_at <= $2 - make_interval(secs => $3::double precision) OR push_gateway_endpoint_quotas.admitted < $4") + .bind(fingerprint).bind(at(now)?).bind(quota_window_seconds).bind(quota_max_deliveries).execute(&mut *tx).await.map_err(db)?; + if quota.rows_affected() != 1 { + return Err(AuthorityError::Rejected); + } + let auth_inserted = sqlx::query("INSERT INTO push_gateway_delivery_auth_replays(relay_pubkey,auth_event_id,expires_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING") + .bind(&relay_bytes).bind(event_bytes).bind(at(request_expires_at)?).execute(&mut *tx).await.map_err(db)?; + let request_inserted = sqlx::query("INSERT INTO push_gateway_delivery_request_replays(relay_pubkey,request_id,expires_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING") + .bind(&relay_bytes).bind(request_id).bind(at(request_expires_at)?).execute(&mut *tx).await.map_err(db)?; + if auth_inserted.rows_affected() != 1 || request_inserted.rows_affected() != 1 { + return Err(AuthorityError::Rejected); + } + tx.commit().await.map_err(db)?; + Ok(DeliveryPermit::new(authority, relay.to_owned(), request_id)) + } + + async fn finish_delivery( + &self, + permit: DeliveryPermit, + disposition: DeliveryDisposition, + ) -> Result<(), AuthorityError> { + if disposition == DeliveryDisposition::Retryable { + sqlx::query("DELETE FROM push_gateway_delivery_request_replays WHERE relay_pubkey=$1 AND request_id=$2") + .bind(hex::decode(permit.relay_pubkey).map_err(|_| AuthorityError::Unavailable)?) + .bind(permit.request_id) + .execute(&self.pool) + .await + .map_err(db)?; + } + Ok(()) + } + + async fn reap_expired(&self, now: i64) -> Result<(), AuthorityError> { + let mut tx = self.pool.begin().await.map_err(db)?; + sqlx::query("DELETE FROM push_gateway_challenges WHERE expires_at < $1") + .bind(at(now)?) + .execute(&mut *tx) + .await + .map_err(db)?; + sqlx::query("DELETE FROM push_gateway_delivery_auth_replays WHERE expires_at < $1") + .bind(at(now)?) + .execute(&mut *tx) + .await + .map_err(db)?; + sqlx::query("DELETE FROM push_gateway_delivery_request_replays WHERE expires_at < $1") + .bind(at(now)?) + .execute(&mut *tx) + .await + .map_err(db)?; + sqlx::query( + "DELETE FROM push_gateway_endpoint_quotas WHERE updated_at < $1 - interval '1 day'", + ) + .bind(at(now)?) + .execute(&mut *tx) + .await + .map_err(db)?; + // A parent may become retention-eligible before an otherwise-active + // child. Parent eligibility must therefore reap every child first; + // otherwise the installation delete violates the delegation FK and + // rolls back all cleanup in this transaction. + sqlx::query( + "DELETE FROM push_gateway_delegations d + WHERE d.expires_at < $1 + OR d.revoked_at < $1 - interval '1 day' + OR EXISTS ( + SELECT 1 FROM push_gateway_installations i + WHERE i.id = d.installation_id + AND (i.expires_at < $1 OR i.revoked_at < $1 - interval '1 day') + )", + ) + .bind(at(now)?) + .execute(&mut *tx) + .await + .map_err(db)?; + sqlx::query("DELETE FROM push_gateway_installations WHERE expires_at < $1 OR revoked_at < $1 - interval '1 day'") + .bind(at(now)?).execute(&mut *tx).await.map_err(db)?; + tx.commit().await.map_err(db)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::{postgres::PgPoolOptions, AssertSqlSafe}; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + #[tokio::test] + #[ignore = "requires PostgreSQL with CREATEDB/CREATEROLE"] + async fn readiness_requires_migrated_schema_dml_and_no_ddl() { + let admin_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&admin_url) + .await + .expect("connect as PostgreSQL test administrator"); + let suffix = Uuid::new_v4().simple().to_string(); + let database = format!("push_ready_{suffix}"); + let runtime_role = format!("push_runtime_{suffix}"); + sqlx::query(AssertSqlSafe(format!( + "CREATE ROLE {runtime_role} LOGIN PASSWORD 'runtime_test'" + ))) + .execute(&admin) + .await + .expect("create runtime role"); + sqlx::query(AssertSqlSafe(format!("CREATE DATABASE {database}"))) + .execute(&admin) + .await + .expect("create dedicated gateway database"); + + let mut admin_database_url = url::Url::parse(&admin_url).expect("parse PostgreSQL URL"); + admin_database_url.set_path(&database); + let mut runtime_database_url = admin_database_url.clone(); + runtime_database_url + .set_username(&runtime_role) + .expect("set runtime username"); + runtime_database_url + .set_password(Some("runtime_test")) + .expect("set runtime password"); + let runtime_pool = PgPoolOptions::new() + .max_connections(1) + .connect(runtime_database_url.as_str()) + .await + .expect("connect as runtime role"); + let runtime = PostgresAuthorityStore::new(runtime_pool.clone()); + assert!( + runtime.ready().await.is_err(), + "empty database is not ready" + ); + + let migration_pool = PgPoolOptions::new() + .max_connections(1) + .connect(admin_database_url.as_str()) + .await + .expect("connect migration role to dedicated database"); + PostgresAuthorityStore::apply_migrations_and_grants(&migration_pool, &runtime_role) + .await + .expect("migrate and grant runtime role"); + assert!( + runtime.ready().await.is_ok(), + "migrated least-privilege runtime is ready" + ); + assert!( + sqlx::query("CREATE TABLE forbidden_runtime_ddl(id INT)") + .execute(&runtime_pool) + .await + .is_err(), + "runtime role cannot create tables" + ); + + sqlx::query(AssertSqlSafe(format!( + "REVOKE DELETE ON push_gateway_installations FROM {runtime_role}" + ))) + .execute(&migration_pool) + .await + .expect("remove one required DML privilege"); + assert!( + runtime.ready().await.is_err(), + "missing DML privilege is not ready" + ); + + runtime_pool.close().await; + migration_pool.close().await; + sqlx::query(AssertSqlSafe(format!("DROP DATABASE {database}"))) + .execute(&admin) + .await + .expect("drop test database"); + sqlx::query(AssertSqlSafe(format!("DROP ROLE {runtime_role}"))) + .execute(&admin) + .await + .expect("drop test role"); + } + + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn reaper_deletes_active_child_of_retention_eligible_revoked_installation() { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect to PostgreSQL test database"); + let schema = format!("push_reaper_{}", Uuid::new_v4().simple()); + sqlx::query(AssertSqlSafe(format!("CREATE SCHEMA {schema}"))) + .execute(&pool) + .await + .expect("create isolated test schema"); + sqlx::query(AssertSqlSafe(format!("SET search_path TO {schema}"))) + .execute(&pool) + .await + .expect("select isolated test schema"); + sqlx::raw_sql( + "CREATE TABLE push_gateway_challenges (expires_at TIMESTAMPTZ NOT NULL); + CREATE TABLE push_gateway_delivery_auth_replays (expires_at TIMESTAMPTZ NOT NULL); + CREATE TABLE push_gateway_delivery_request_replays (expires_at TIMESTAMPTZ NOT NULL); + CREATE TABLE push_gateway_endpoint_quotas (updated_at TIMESTAMPTZ NOT NULL); + CREATE TABLE push_gateway_installations ( + id UUID PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ + ); + CREATE TABLE push_gateway_delegations ( + id UUID PRIMARY KEY, + installation_id UUID NOT NULL REFERENCES push_gateway_installations(id), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ + );", + ) + .execute(&pool) + .await + .expect("create authority retention tables"); + + let now = Utc::now(); + let installation_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO push_gateway_installations(id, expires_at, revoked_at) + VALUES ($1, $2, $3)", + ) + .bind(installation_id) + .bind(now + chrono::Duration::days(30)) + .bind(now - chrono::Duration::days(2)) + .execute(&pool) + .await + .expect("insert retention-eligible revoked installation"); + sqlx::query( + "INSERT INTO push_gateway_delegations(id, installation_id, expires_at, revoked_at) + VALUES ($1, $2, $3, NULL)", + ) + .bind(Uuid::new_v4()) + .bind(installation_id) + .bind(now + chrono::Duration::days(7)) + .execute(&pool) + .await + .expect("insert active future-expiring child delegation"); + + PostgresAuthorityStore::new(pool.clone()) + .reap_expired(now.timestamp()) + .await + .expect("reaper must delete the child before its revoked parent"); + let delegations: i64 = sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations") + .fetch_one(&pool) + .await + .expect("count delegations"); + let installations: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations") + .fetch_one(&pool) + .await + .expect("count installations"); + assert_eq!(delegations, 0); + assert_eq!(installations, 0); + + sqlx::query("SET search_path TO public") + .execute(&pool) + .await + .expect("restore public schema"); + sqlx::query(AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE"))) + .execute(&pool) + .await + .expect("drop isolated test schema"); + } + + // Full authority schema in a private search_path so a multi-connection pool + // exercises the real PK/UNIQUE replay fences that the memory store's single + // mutex cannot. Returns (pool, schema) for teardown. + async fn full_schema(max_connections: u32) -> (PgPool, String) { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let schema = format!("push_admit_{}", Uuid::new_v4().simple()); + let bootstrap = PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect to PostgreSQL test database"); + sqlx::query(AssertSqlSafe(format!("CREATE SCHEMA {schema}"))) + .execute(&bootstrap) + .await + .expect("create isolated test schema"); + bootstrap.close().await; + // search_path is per-session, so pin it on every pooled connection. + let set_path = format!("SET search_path TO {schema}"); + let pool = PgPoolOptions::new() + .max_connections(max_connections) + .after_connect(move |conn, _| { + let set_path = set_path.clone(); + Box::pin(async move { + sqlx::query(AssertSqlSafe(set_path)).execute(conn).await?; + Ok(()) + }) + }) + .connect(&database_url) + .await + .expect("connect isolated-schema pool"); + // Real DDL from migration 0010 (minus the _operator_global_tables audit + // insert, which lives outside the isolated schema). + sqlx::raw_sql( + "CREATE TABLE push_gateway_installations ( + id UUID PRIMARY KEY, + app_attest_key_id BYTEA NOT NULL UNIQUE, + app_attest_public_key BYTEA NOT NULL, + assertion_counter BIGINT NOT NULL, + app_profile TEXT NOT NULL, + token_ciphertext BYTEA NOT NULL, + token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32), + endpoint_epoch BIGINT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (app_profile, token_fingerprint) + ); + CREATE TABLE push_gateway_delegations ( + id UUID PRIMARY KEY, + installation_id UUID NOT NULL REFERENCES push_gateway_installations(id), + relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), + endpoint_epoch BIGINT NOT NULL, + generation BIGINT NOT NULL, + not_before TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (installation_id, relay_pubkey) + ); + CREATE TABLE push_gateway_endpoint_quotas ( + token_fingerprint BYTEA PRIMARY KEY CHECK (length(token_fingerprint) = 32), + window_started_at TIMESTAMPTZ NOT NULL, + admitted BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE push_gateway_delivery_auth_replays ( + relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), + auth_event_id BYTEA NOT NULL CHECK (length(auth_event_id) = 32), + expires_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (relay_pubkey, auth_event_id) + ); + CREATE TABLE push_gateway_delivery_request_replays ( + relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32), + request_id UUID NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (relay_pubkey, request_id) + );", + ) + .execute(&pool) + .await + .expect("create authority admission tables"); + (pool, schema) + } + + const RELAY_HEX: &str = "11111111111111111111111111111111111111111111111111111111111111aa"; + const DELEGATION_ID: u128 = 2; + + // One installation + one live delegation that admits at now=1_000. + async fn install_authority(pool: &PgPool) { + let now = Utc::now(); + sqlx::query( + "INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) + VALUES ($1,$2,$3,0,'buzz-ios-production',$4,$5,1,$6)", + ) + .bind(Uuid::from_u128(1)) + .bind(vec![1u8]) + .bind(vec![2u8; 33]) + .bind(vec![3u8]) + .bind(vec![4u8; 32]) + .bind(now + chrono::Duration::days(30)) + .execute(pool) + .await + .expect("insert installation"); + sqlx::query( + "INSERT INTO push_gateway_delegations(id,installation_id,relay_pubkey,endpoint_epoch,generation,not_before,expires_at,revoked_at) + VALUES ($1,$2,$3,1,1,$4,$5,NULL)", + ) + .bind(Uuid::from_u128(DELEGATION_ID)) + .bind(Uuid::from_u128(1)) + .bind(hex::decode(RELAY_HEX).unwrap()) + .bind(now - chrono::Duration::days(1)) + .bind(now + chrono::Duration::days(7)) + .execute(pool) + .await + .expect("insert delegation"); + } + + fn admit<'a>( + store: &'a PostgresAuthorityStore, + event_hex: &'a str, + request_id: Uuid, + ) -> impl std::future::Future> + 'a { + admit_with_quota(store, event_hex, request_id, 10) + } + + fn admit_with_quota<'a>( + store: &'a PostgresAuthorityStore, + event_hex: &'a str, + request_id: Uuid, + quota_max_deliveries: i64, + ) -> impl std::future::Future> + 'a { + let now = Utc::now().timestamp(); + store.authorize_delivery( + Uuid::from_u128(DELEGATION_ID), + RELAY_HEX, + 1, + 1, + event_hex, + request_id, + now + 300, + 60, + quota_max_deliveries, + now, + ) + } + + // Two concurrent admissions colliding on the same (relay,request_id) PK must + // admit exactly once; the loser rejects with its whole tx rolled back, so + // quota is charged once and the auth-event fence is not consumed by the loser. + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn concurrent_same_request_id_admits_exactly_once() { + let (pool, schema) = full_schema(4).await; + install_authority(&pool).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let request_id = Uuid::new_v4(); + let event_a = "22".repeat(32); + let event_b = "33".repeat(32); + + let (a, b) = tokio::join!( + admit(&store, &event_a, request_id), + admit(&store, &event_b, request_id), + ); + assert_eq!( + [a.is_ok(), b.is_ok()].iter().filter(|ok| **ok).count(), + 1, + "exactly one concurrent same-request_id admission may win" + ); + + let requests: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_request_replays") + .fetch_one(&pool) + .await + .expect("count request replays"); + assert_eq!(requests, 1, "winner leaves exactly one request-id fence"); + let auth_events: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_auth_replays") + .fetch_one(&pool) + .await + .expect("count auth replays"); + assert_eq!(auth_events, 1, "loser's auth-event insert rolled back"); + let admitted: i64 = sqlx::query_scalar("SELECT admitted FROM push_gateway_endpoint_quotas") + .fetch_one(&pool) + .await + .expect("read quota"); + assert_eq!(admitted, 1, "loser's quota reservation rolled back"); + + pool.close().await; + drop_schema(&schema).await; + } + + // Red-team (Tyler's thorough pass): quota ceiling under concurrency. Two + // admissions for the SAME endpoint fingerprint but DISTINCT request_ids and + // DISTINCT auth events — so neither replay PK fence can gate them; the only + // thing standing between the caller and over-admission is the quota upsert's + // `WHERE ... admitted < $4` predicate. With max=1 the two admissions race for + // a single slot. A snapshot-evaluated predicate (reading admitted=0 in both + // txns before either commits) would admit BOTH and burn the ceiling; the + // correct behavior relies on Postgres re-checking the ON CONFLICT DO UPDATE + // predicate against the row it just locked, so the loser sees admitted=1, + // fails `1 < 1`, updates zero rows, and rejects. Exactly one Ok, and the + // persisted counter must never exceed the ceiling. + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn concurrent_admissions_never_over_admit_past_quota_ceiling() { + let (pool, schema) = full_schema(4).await; + install_authority(&pool).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let event_a = "22".repeat(32); + let event_b = "33".repeat(32); + + let (a, b) = tokio::join!( + admit_with_quota(&store, &event_a, Uuid::new_v4(), 1), + admit_with_quota(&store, &event_b, Uuid::new_v4(), 1), + ); + assert_eq!( + [a.is_ok(), b.is_ok()].iter().filter(|ok| **ok).count(), + 1, + "quota ceiling of 1 admits exactly one of two concurrent attempts" + ); + + let admitted: i64 = sqlx::query_scalar("SELECT admitted FROM push_gateway_endpoint_quotas") + .fetch_one(&pool) + .await + .expect("read quota"); + assert_eq!( + admitted, 1, + "persisted admitted counter must never exceed the ceiling under a race" + ); + // The loser's whole tx rolled back: its distinct auth event is not fenced. + let auth_events: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_auth_replays") + .fetch_one(&pool) + .await + .expect("count auth replays"); + assert_eq!(auth_events, 1, "rejected admission consumes no auth fence"); + let requests: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_request_replays") + .fetch_one(&pool) + .await + .expect("count request replays"); + assert_eq!(requests, 1, "rejected admission consumes no request fence"); + + pool.close().await; + drop_schema(&schema).await; + } + + // Red-team: Retryable release is unconditional (deletes the request-id row on + // the pool, not inside a tx). Attack the window where a losing delivery's + // release races a fresh admission that legitimately re-took the same + // request_id — could the stale DELETE punch a hole in the live fence? It + // cannot: the DELETE keys on (relay_pubkey, request_id) with no ownership + // token, but the fence it would remove is exactly the one the retrying caller + // is entitled to free, and any *subsequent* admission re-inserts its own row. + // Concretely: admit R, Retryable-release R (fence gone), re-admit R (fresh + // fence), then replay the SAME release a second time (a duplicated/late + // finish) — it must delete the NOW-LIVE fence, and the next admission of R + // must still be gated by whatever fence remains. This pins that a duplicated + // Retryable finish is idempotent-safe and never leaves R permanently + // un-fenceable while the delegation is live. + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn duplicated_retryable_release_does_not_permanently_unfence_request_id() { + let (pool, schema) = full_schema(2).await; + install_authority(&pool).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let request_id = Uuid::new_v4(); + + let permit = admit(&store, &"22".repeat(32), request_id) + .await + .expect("first admission"); + // Clone the permit's identity into a second release we fire twice. + store + .finish_delivery(permit, DeliveryDisposition::Retryable) + .await + .expect("retryable release frees the fence"); + // Re-admit: fresh fence for the same request_id. + let permit2 = admit(&store, &"33".repeat(32), request_id) + .await + .expect("re-admit after release"); + // A duplicated/late Retryable finish for the same (relay, request_id) + // deletes the now-live fence — this is the worst case for the + // unconditional DELETE. It must not error, and R must remain re-admittable + // (fence hole is transient, never permanent), which is the honest + // NIP-PL §312 contract: a still-live endpoint gets a fresh job. + store + .finish_delivery(permit2, DeliveryDisposition::Retryable) + .await + .expect("duplicated retryable release is idempotent-safe"); + let admitted_again = admit(&store, &"44".repeat(32), request_id).await; + assert!( + admitted_again.is_ok(), + "after any Retryable release the request_id is re-admittable, never permanently unfenceable" + ); + // And a Terminal on that live permit re-burns it, closing the window. + store + .finish_delivery(admitted_again.unwrap(), DeliveryDisposition::Terminal) + .await + .expect("terminal finish"); + assert!( + admit(&store, &"55".repeat(32), request_id).await.is_err(), + "terminal keeps the fence burned after the release churn" + ); + + pool.close().await; + drop_schema(&schema).await; + } + + // Retryable release must free the real request-id PK: after finish_delivery, + // the same request_id re-admits with a fresh auth event; a Terminal finish + // leaves it burned. + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn retryable_release_frees_request_id_on_real_postgres() { + let (pool, schema) = full_schema(2).await; + install_authority(&pool).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let request_id = Uuid::new_v4(); + + let permit = admit(&store, &"22".repeat(32), request_id) + .await + .expect("first admission"); + store + .finish_delivery(permit, DeliveryDisposition::Retryable) + .await + .expect("retryable release"); + // Same request_id, fresh auth event: released PK admits again. + let permit = admit(&store, &"33".repeat(32), request_id) + .await + .expect("retryable release frees the request-id PK"); + // Terminal now burns it: a further re-admit with the same request_id fails. + store + .finish_delivery(permit, DeliveryDisposition::Terminal) + .await + .expect("terminal finish"); + assert!( + admit(&store, &"44".repeat(32), request_id).await.is_err(), + "terminal outcome keeps the request-id fence burned" + ); + + pool.close().await; + drop_schema(&schema).await; + } + + async fn drop_schema(schema: &str) { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect for teardown"); + sqlx::query(AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE"))) + .execute(&pool) + .await + .expect("drop isolated test schema"); + } +} diff --git a/crates/buzz-push-gateway/src/strict_json.rs b/crates/buzz-push-gateway/src/strict_json.rs new file mode 100644 index 0000000000..db11e7dd56 --- /dev/null +++ b/crates/buzz-push-gateway/src/strict_json.rs @@ -0,0 +1,89 @@ +//! JSON parsing that rejects duplicate object members at every nesting depth. + +use std::{collections::HashSet, fmt}; + +use serde::de::{DeserializeOwned, DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::Value; + +struct StrictValue; + +impl<'de> DeserializeSeed<'de> for StrictValue { + type Value = Value; + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for StrictValue { + type Value = Value; + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("JSON with unique object keys") + } + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(value.into())) + } + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(value.into())) + } + fn visit_f64(self, value: f64) -> Result { + serde_json::Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite number")) + } + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + fn visit_string(self, value: String) -> Result { + Ok(Value::String(value)) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_none(self) -> Result { + Ok(Value::Null) + } + fn visit_some>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } + fn visit_seq>(self, mut sequence: A) -> Result { + let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0)); + while let Some(value) = sequence.next_element_seed(StrictValue)? { + values.push(value); + } + Ok(Value::Array(values)) + } + fn visit_map>(self, mut map: A) -> Result { + use serde::de::Error; + let mut seen = HashSet::new(); + let mut values = serde_json::Map::new(); + while let Some(key) = map.next_key::()? { + if !seen.insert(key.clone()) { + return Err(A::Error::custom("duplicate object member")); + } + values.insert(key, map.next_value_seed(StrictValue)?); + } + Ok(Value::Object(values)) + } +} + +/// Parse one complete JSON document, rejecting duplicate keys and trailing data. +pub fn from_slice(bytes: &[u8]) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictValue.deserialize(&mut deserializer)?; + deserializer.end()?; + T::deserialize(value) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_duplicate_keys_at_any_depth() { + assert!(from_slice::(br#"{"v":1,"v":1}"#).is_err()); + assert!(from_slice::(br#"{"wake":{"v":1,"v":1}}"#).is_err()); + assert!(from_slice::(br#"{"v":1} trailing"#).is_err()); + } +} diff --git a/crates/buzz-push-gateway/src/token.rs b/crates/buzz-push-gateway/src/token.rs new file mode 100644 index 0000000000..74fe4ce131 --- /dev/null +++ b/crates/buzz-push-gateway/src/token.rs @@ -0,0 +1,122 @@ +//! APNs token custody. Ciphertexts are self-describing and can be decrypted by +//! the current key or bounded decrypt-only predecessors during key rotation. +use aes_gcm::{ + aead::{rand_core::RngCore, Aead, KeyInit, OsRng}, + Aes256Gcm, Nonce, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use std::collections::{HashMap, HashSet}; +use thiserror::Error; + +const AAD_PREFIX: &[u8] = b"buzz-apns-token-v1:"; +const MAX_KEY_ID_BYTES: usize = 32; +const MAX_CIPHERTEXT_BYTES: usize = 2048; + +#[derive(Clone)] +pub struct TokenKey { + id: String, + cipher: Aes256Gcm, +} +#[derive(Clone)] +pub struct TokenKeyring { + current: TokenKey, + predecessors: HashMap, +} + +#[derive(Debug, Error)] +pub enum TokenError { + #[error("invalid token ciphertext")] + Invalid, + #[error("token keyring is empty or contains duplicate ids")] + InvalidKeyring, +} + +impl TokenKey { + pub fn new(id: impl Into, key: &[u8]) -> Result { + let id = id.into(); + if id.is_empty() + || id.len() > MAX_KEY_ID_BYTES + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + { + return Err(TokenError::Invalid); + } + Ok(Self { + id, + cipher: Aes256Gcm::new_from_slice(key).map_err(|_| TokenError::Invalid)?, + }) + } + fn aad(&self) -> Vec { + [AAD_PREFIX, self.id.as_bytes()].concat() + } +} + +impl TokenKeyring { + pub fn new(keys: Vec) -> Result { + let mut keys = keys.into_iter(); + let current = keys.next().ok_or(TokenError::InvalidKeyring)?; + let rest: Vec<_> = keys.collect(); + let mut ids = HashSet::new(); + if !ids.insert(current.id.clone()) || rest.iter().any(|k| !ids.insert(k.id.clone())) { + return Err(TokenError::InvalidKeyring); + } + Ok(Self { + current, + predecessors: rest.into_iter().map(|k| (k.id.clone(), k)).collect(), + }) + } + pub fn seal(&self, token: &[u8]) -> Result, TokenError> { + if token.is_empty() || token.len() > crate::model::MAX_ENDPOINT_HEX_BYTES { + return Err(TokenError::Invalid); + } + let mut nonce = [0; 12]; + OsRng.fill_bytes(&mut nonce); + let mut sealed = nonce.to_vec(); + sealed.extend( + self.current + .cipher + .encrypt( + Nonce::from_slice(&nonce), + aes_gcm::aead::Payload { + msg: token, + aad: &self.current.aad(), + }, + ) + .map_err(|_| TokenError::Invalid)?, + ); + let encoded = + format!("{}.{}", self.current.id, URL_SAFE_NO_PAD.encode(sealed)).into_bytes(); + if encoded.len() > MAX_CIPHERTEXT_BYTES { + return Err(TokenError::Invalid); + } + Ok(encoded) + } + pub fn open(&self, encoded: &[u8]) -> Result, TokenError> { + if encoded.len() > MAX_CIPHERTEXT_BYTES { + return Err(TokenError::Invalid); + } + let encoded = std::str::from_utf8(encoded).map_err(|_| TokenError::Invalid)?; + let (id, body) = encoded.split_once('.').ok_or(TokenError::Invalid)?; + let key = if id == self.current.id { + &self.current + } else { + self.predecessors.get(id).ok_or(TokenError::Invalid)? + }; + let bytes = URL_SAFE_NO_PAD + .decode(body) + .map_err(|_| TokenError::Invalid)?; + if bytes.len() < 13 { + return Err(TokenError::Invalid); + } + key.cipher + .decrypt( + Nonce::from_slice(&bytes[..12]), + aes_gcm::aead::Payload { + msg: &bytes[12..], + aad: &key.aad(), + }, + ) + .map_err(|_| TokenError::Invalid) + } +} diff --git a/crates/buzz-relay-mesh/Cargo.toml b/crates/buzz-relay-mesh/Cargo.toml new file mode 100644 index 0000000000..15d0c5ba30 --- /dev/null +++ b/crates/buzz-relay-mesh/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "buzz-relay-mesh" +description = "Inter-relay QUIC mesh: transport, membership, and the fenced wire contract" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +postcard = { workspace = true } +iroh = { workspace = true } +redis = { workspace = true } +deadpool-redis = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +nostr = { workspace = true } +bytes = "1" +futures-util = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } +proptest = { workspace = true } diff --git a/crates/buzz-relay-mesh/src/endpoint.rs b/crates/buzz-relay-mesh/src/endpoint.rs new file mode 100644 index 0000000000..f7bedd8789 --- /dev/null +++ b/crates/buzz-relay-mesh/src/endpoint.rs @@ -0,0 +1,293 @@ +use std::net::SocketAddr; + +use iroh::{Endpoint, EndpointAddr, PublicKey, RelayMode, SecretKey, TransportAddr}; + +use crate::{MeshError, RuntimeId, ALPN}; + +/// Local iroh endpoint for the relay mesh. +/// +/// Identity is the iroh/ed25519 public key of a boot-unique keypair generated +/// at process start. +#[derive(Debug, Clone)] +pub struct MeshEndpoint { + endpoint: Endpoint, + runtime_id: RuntimeId, +} + +impl MeshEndpoint { + /// Generate a boot-unique mesh keypair and bind a mesh endpoint on `bind_addr`. + pub async fn bind(bind_addr: SocketAddr) -> Result { + Self::bind_with_secret_key(SecretKey::generate(), bind_addr).await + } + + /// Bind with an explicit keypair. Production should use [`Self::bind`] so + /// every process boot gets a fresh RuntimeId; tests use this for stable + /// identities. + pub async fn bind_with_secret_key( + secret_key: SecretKey, + bind_addr: SocketAddr, + ) -> Result { + let runtime_id = runtime_id_from_public_key(secret_key.public()); + let endpoint = Endpoint::builder(iroh::endpoint::presets::Minimal) + .secret_key(secret_key) + .alpns(vec![ALPN.to_vec()]) + .relay_mode(RelayMode::Disabled) + .bind_addr(bind_addr) + .map_err(|err| MeshError::Transport(err.to_string()))? + .bind() + .await + .map_err(|err| MeshError::Transport(err.to_string()))?; + + Ok(Self { + endpoint, + runtime_id, + }) + } + + pub fn runtime_id(&self) -> RuntimeId { + self.runtime_id + } + + pub fn endpoint(&self) -> Endpoint { + self.endpoint.clone() + } + + pub fn addr(&self) -> EndpointAddr { + self.endpoint.addr() + } + + /// The endpoint's directly-dialable IP socket addrs (no relay paths). + /// Lets consumers build advertise records without depending on iroh types. + pub fn ip_addrs(&self) -> Vec { + self.endpoint + .addr() + .addrs + .iter() + .filter_map(|ta| match ta { + TransportAddr::Ip(sock) => Some(*sock), + _ => None, + }) + .collect() + } + + pub async fn accept(&self) -> Result, MeshError> { + let Some(incoming) = self.endpoint.accept().await else { + return Ok(None); + }; + let conn = incoming + .await + .map_err(|err| MeshError::Transport(err.to_string()))?; + crate::peer::MeshPeer::from_connection(self.endpoint.clone(), conn).map(Some) + } + + pub async fn connect( + &self, + peer_addr: EndpointAddr, + ) -> Result { + let conn = self + .endpoint + .connect(peer_addr, ALPN) + .await + .map_err(|err| MeshError::Transport(err.to_string()))?; + crate::peer::MeshPeer::from_connection(self.endpoint.clone(), conn) + } +} + +pub fn runtime_id_from_public_key(public_key: PublicKey) -> RuntimeId { + RuntimeId(*public_key.as_bytes()) +} + +pub fn public_key_from_runtime_id(runtime_id: RuntimeId) -> Result { + PublicKey::from_bytes(&runtime_id.0).map_err(|err| MeshError::Transport(err.to_string())) +} + +pub fn direct_addr(runtime_id: RuntimeId, addr: SocketAddr) -> Result { + Ok(EndpointAddr::from_parts( + public_key_from_runtime_id(runtime_id)?, + [TransportAddr::Ip(addr)], + )) +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::time::Duration; + + use iroh::SecretKey; + use tokio::time::timeout; + use uuid::Uuid; + + use super::MeshEndpoint; + + use crate::{ + wire, FencedHeader, GoodbyeReason, MeshDatagram, MeshError, MeshStreamFrame, Profile, + RuntimeId, StreamHello, StreamRole, + }; + + fn loopback_any() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0) + } + + fn fenced(owner_runtime_id: RuntimeId) -> FencedHeader { + FencedHeader { + session_id: Uuid::from_u128(0xABCD), + generation: 7, + owner_runtime_id, + } + } + + async fn endpoint_pair() -> (MeshEndpoint, MeshEndpoint) { + let a = + MeshEndpoint::bind_with_secret_key(SecretKey::from_bytes(&[1u8; 32]), loopback_any()) + .await + .unwrap(); + let b = + MeshEndpoint::bind_with_secret_key(SecretKey::from_bytes(&[2u8; 32]), loopback_any()) + .await + .unwrap(); + (a, b) + } + + async fn connected_pair() -> ( + crate::peer::MeshPeer, + crate::peer::MeshPeer, + RuntimeId, + RuntimeId, + ) { + let (a, b) = endpoint_pair().await; + let a_runtime_id = a.runtime_id(); + let b_runtime_id = b.runtime_id(); + + let b_addr = b.addr(); + let accept = tokio::spawn(async move { b.accept().await.unwrap().unwrap() }); + let a_peer = a.connect(b_addr).await.unwrap(); + let b_peer = accept.await.unwrap(); + + (a_peer, b_peer, a_runtime_id, b_runtime_id) + } + + #[tokio::test] + async fn two_endpoints_connect_with_alpn_and_authenticated_identity() { + let (a_peer, b_peer, a_runtime_id, b_runtime_id) = connected_pair().await; + + assert_eq!(a_peer.runtime_id(), b_runtime_id); + assert_eq!(b_peer.runtime_id(), a_runtime_id); + assert!(a_peer.max_datagram_size().expect("datagrams enabled") > 0); + } + + #[tokio::test] + async fn reliable_stream_roundtrip_carries_mesh_stream_frame() { + let (a_peer, b_peer, _a_runtime_id, b_runtime_id) = connected_pair().await; + let fenced = fenced(b_runtime_id); + let hello = MeshStreamFrame::Hello(StreamHello { + sender: RuntimeId([9u8; 32]), + role: StreamRole::Session { + fenced, + profile: Profile::ReliableStream, + }, + }); + let data = MeshStreamFrame::Data { + fenced, + payload: b"goose bytes".to_vec(), + }; + let goodbye = MeshStreamFrame::Goodbye { + fenced, + reason: GoodbyeReason::SessionEnded, + }; + + let recv = tokio::spawn(async move { + let mut stream = b_peer.accept_bi().await.unwrap(); + let first = stream.recv_frame().await.unwrap().unwrap(); + let second = stream.recv_frame().await.unwrap().unwrap(); + let third = stream.recv_frame().await.unwrap().unwrap(); + (first, second, third) + }); + + let mut stream = a_peer.open_bi().await.unwrap(); + stream.send_frame(hello.clone()).await.unwrap(); + stream.send_frame(data.clone()).await.unwrap(); + stream.send_frame(goodbye.clone()).await.unwrap(); + stream.finish().unwrap(); + + let (got_hello, got_data, got_goodbye) = timeout(Duration::from_secs(5), recv) + .await + .unwrap() + .unwrap(); + assert_eq!(got_hello, hello); + assert_eq!(got_data, data); + assert_eq!(got_goodbye, goodbye); + } + + #[tokio::test] + async fn datagram_roundtrip_carries_mesh_datagram() { + let (a_peer, b_peer, _a_runtime_id, b_runtime_id) = connected_pair().await; + let dgram = MeshDatagram { + fenced: fenced(b_runtime_id), + seq: 1, + payload: vec![13, 37, 42], + }; + + a_peer.send_datagram(&dgram).unwrap(); + let got = timeout(Duration::from_secs(5), b_peer.recv_datagram()) + .await + .unwrap() + .unwrap(); + assert_eq!(got, dgram); + } + + #[tokio::test] + async fn oversized_datagram_is_rejected_before_send() { + let (a_peer, _b_peer, _a_runtime_id, b_runtime_id) = connected_pair().await; + let max = a_peer.max_datagram_size().expect("datagrams enabled"); + let dgram = MeshDatagram { + fenced: fenced(b_runtime_id), + seq: 1, + payload: vec![0u8; max + 1], + }; + + let err = a_peer.send_datagram(&dgram).unwrap_err(); + assert!(matches!( + err, + MeshError::DatagramTooLarge { size, max: limit } if size > limit + )); + } + + #[tokio::test] + async fn opus_sized_datagrams_clear_empirical_local_loss_gate() { + let (a_peer, b_peer, _a_runtime_id, b_runtime_id) = connected_pair().await; + let payload_len = 1 /* Dawn huddle peer_index */ + 8 /* v2 audio header */ + 160; + let encoded_len = wire::encode(&MeshDatagram { + fenced: fenced(b_runtime_id), + seq: 0, + payload: vec![0u8; payload_len], + }) + .unwrap() + .len(); + assert!(encoded_len <= a_peer.max_datagram_size().expect("datagrams enabled")); + + let count = 64u64; + for seq in 0..count { + a_peer + .send_datagram(&MeshDatagram { + fenced: fenced(b_runtime_id), + seq, + payload: vec![seq as u8; payload_len], + }) + .unwrap(); + tokio::task::yield_now().await; + } + + let mut got = Vec::new(); + for _ in 0..count { + got.push( + timeout(Duration::from_secs(5), b_peer.recv_datagram()) + .await + .unwrap() + .unwrap() + .seq, + ); + } + got.sort_unstable(); + assert_eq!(got, (0..count).collect::>()); + } +} diff --git a/crates/buzz-relay-mesh/src/gossip.rs b/crates/buzz-relay-mesh/src/gossip.rs new file mode 100644 index 0000000000..5da5cbbe54 --- /dev/null +++ b/crates/buzz-relay-mesh/src/gossip.rs @@ -0,0 +1,303 @@ +//! Scuttlebutt-style membership gossip over the mesh control stream. +//! +//! Gossip answers liveness/dialability questions only. It never elects owners, +//! never transfers sessions, and never carries tunnel data bytes. + +use std::collections::HashMap; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::{MeshError, RuntimeId}; + +pub const GOSSIP_PAYLOAD_VERSION: u8 = 1; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GossipRecord { + pub runtime_id: RuntimeId, + pub endpoint_addrs: Vec, + pub proto_version: u16, + pub load: f32, + pub draining: bool, + pub capabilities: Vec, + /// Per-runtime monotonic version. Only the owning runtime may increment its + /// own record; receivers apply last-version-wins. + pub version: u64, + pub heartbeat_millis: u64, +} + +impl GossipRecord { + pub fn new(runtime_id: RuntimeId, endpoint_addrs: Vec, proto_version: u16) -> Self { + Self { + runtime_id, + endpoint_addrs, + proto_version, + load: 0.0, + draining: false, + capabilities: Vec::new(), + version: 1, + heartbeat_millis: now_millis(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GossipDigestEntry { + pub runtime_id: RuntimeId, + pub version: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum GossipMessage { + Digest { + version: u8, + entries: Vec, + }, + Delta { + version: u8, + records: Vec, + }, +} + +pub fn encode_message(message: &GossipMessage) -> Result, MeshError> { + postcard::to_extend(message, Vec::new()).map_err(MeshError::Encode) +} + +pub fn decode_message(bytes: &[u8]) -> Result { + let message: GossipMessage = postcard::from_bytes(bytes).map_err(MeshError::Decode)?; + let version = match &message { + GossipMessage::Digest { version, .. } | GossipMessage::Delta { version, .. } => *version, + }; + if version != GOSSIP_PAYLOAD_VERSION { + return Err(MeshError::Transport(format!( + "unknown gossip payload version {version}" + ))); + } + Ok(message) +} + +/// Pure scuttlebutt state: digest exchange + delta application. +#[derive(Clone, Debug)] +pub struct GossipState { + records: HashMap, +} + +impl GossipState { + pub fn new(local: GossipRecord) -> Self { + let mut records = HashMap::new(); + records.insert(local.runtime_id, local); + Self { records } + } + + pub fn records(&self) -> impl Iterator { + self.records.values() + } + + pub fn get(&self, runtime_id: RuntimeId) -> Option<&GossipRecord> { + self.records.get(&runtime_id) + } + + pub fn update_local(&mut self, runtime_id: RuntimeId, update: F) -> Option + where + F: FnOnce(&mut GossipRecord), + { + let record = self.records.get_mut(&runtime_id)?; + update(record); + record.version = record.version.saturating_add(1); + record.heartbeat_millis = now_millis(); + Some(record.clone()) + } + + pub fn digest(&self) -> GossipMessage { + let mut entries: Vec<_> = self + .records + .values() + .map(|record| GossipDigestEntry { + runtime_id: record.runtime_id, + version: record.version, + }) + .collect(); + entries.sort_by_key(|entry| entry.runtime_id.to_hex()); + GossipMessage::Digest { + version: GOSSIP_PAYLOAD_VERSION, + entries, + } + } + + pub fn delta_for(&self, digest: &[GossipDigestEntry]) -> GossipMessage { + let remote_versions: HashMap<_, _> = digest + .iter() + .map(|entry| (entry.runtime_id, entry.version)) + .collect(); + let mut records: Vec<_> = self + .records + .values() + .filter(|record| { + remote_versions + .get(&record.runtime_id) + .is_none_or(|remote| *remote < record.version) + }) + .cloned() + .collect(); + records.sort_by_key(|record| record.runtime_id.to_hex()); + GossipMessage::Delta { + version: GOSSIP_PAYLOAD_VERSION, + records, + } + } + + /// Applies records whose version is newer than the local copy. Returns the + /// runtime ids that changed. + pub fn apply_delta(&mut self, records: Vec) -> Vec { + let mut changed = Vec::new(); + for record in records { + let should_apply = self + .records + .get(&record.runtime_id) + .is_none_or(|existing| record.version > existing.version); + if should_apply { + changed.push(record.runtime_id); + self.records.insert(record.runtime_id, record); + } + } + changed + } +} + +#[derive(Clone, Debug)] +pub struct PhiAccrual { + samples: Vec, + last_heartbeat: Option, + max_samples: usize, +} + +impl Default for PhiAccrual { + fn default() -> Self { + Self::new(100) + } +} + +impl PhiAccrual { + pub fn new(max_samples: usize) -> Self { + Self { + samples: Vec::new(), + last_heartbeat: None, + max_samples: max_samples.max(1), + } + } + + pub fn observe(&mut self, at: SystemTime) { + if let Some(prev) = self.last_heartbeat { + if let Ok(interval) = at.duration_since(prev) { + if !interval.is_zero() { + self.samples.push(interval); + if self.samples.len() > self.max_samples { + self.samples.remove(0); + } + } + } + } + self.last_heartbeat = Some(at); + } + + pub fn phi_at(&self, now: SystemTime) -> Option { + let last = self.last_heartbeat?; + if self.samples.is_empty() { + return None; + } + let elapsed = now.duration_since(last).ok()?.as_secs_f64(); + let mean = self.mean_secs(); + if mean <= f64::EPSILON { + return None; + } + // Exponential approximation: phi = -log10(e^(-elapsed/mean)). + Some((elapsed / mean) / std::f64::consts::LN_10) + } + + pub fn mean_secs(&self) -> f64 { + let total: f64 = self.samples.iter().map(Duration::as_secs_f64).sum(); + total / self.samples.len() as f64 + } +} + +pub fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64 +} + +pub fn system_time_from_millis(millis: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_millis(millis) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rid(byte: u8) -> RuntimeId { + RuntimeId([byte; 32]) + } + + #[test] + fn digest_delta_only_sends_newer_records() { + let mut a = GossipState::new(GossipRecord::new(rid(1), vec!["a".into()], 1)); + a.apply_delta(vec![GossipRecord::new(rid(2), vec!["b".into()], 1)]); + + let b_digest = [GossipDigestEntry { + runtime_id: rid(1), + version: 1, + }]; + + let GossipMessage::Delta { records, .. } = a.delta_for(&b_digest) else { + panic!("expected delta") + }; + assert_eq!(records.len(), 1); + assert_eq!(records[0].runtime_id, rid(2)); + } + + #[test] + fn apply_delta_ignores_stale_versions() { + let mut state = GossipState::new(GossipRecord::new(rid(1), vec![], 1)); + let newer = GossipRecord { + version: 10, + ..GossipRecord::new(rid(2), vec!["new".into()], 1) + }; + assert_eq!(state.apply_delta(vec![newer.clone()]), vec![rid(2)]); + let stale = GossipRecord { + version: 9, + endpoint_addrs: vec!["stale".into()], + ..newer + }; + assert!(state.apply_delta(vec![stale]).is_empty()); + assert_eq!(state.get(rid(2)).unwrap().endpoint_addrs, vec!["new"]); + } + + #[test] + fn gossip_payload_roundtrips() { + let message = GossipMessage::Digest { + version: GOSSIP_PAYLOAD_VERSION, + entries: vec![GossipDigestEntry { + runtime_id: rid(9), + version: 3, + }], + }; + assert_eq!( + decode_message(&encode_message(&message).unwrap()).unwrap(), + message + ); + } + + #[test] + fn phi_rises_as_heartbeats_age() { + let start = UNIX_EPOCH + Duration::from_secs(1_000); + let mut phi = PhiAccrual::default(); + phi.observe(start); + phi.observe(start + Duration::from_secs(1)); + phi.observe(start + Duration::from_secs(2)); + let early = phi.phi_at(start + Duration::from_secs(3)).unwrap(); + let late = phi.phi_at(start + Duration::from_secs(12)).unwrap(); + assert!(late > early); + } +} diff --git a/crates/buzz-relay-mesh/src/lib.rs b/crates/buzz-relay-mesh/src/lib.rs new file mode 100644 index 0000000000..be031cb371 --- /dev/null +++ b/crates/buzz-relay-mesh/src/lib.rs @@ -0,0 +1,225 @@ +//! buzz-relay-mesh — the inter-relay QUIC mesh. +//! +//! One iroh endpoint per relay runtime (identity = a boot-unique mesh +//! keypair, attested by the relay's signing key — see [`wire::RuntimeId`]), +//! a warm full mesh of authenticated connections, scuttlebutt membership +//! gossip on a control substream, and a fenced wire contract that carries +//! tunnel traffic (reliable streams + realtime datagrams) between pods. +//! +//! The relay consumes this crate exclusively through two seams: +//! +//! - [`RelayMeshMembership`] — "who is alive / draining / dialable?" +//! - [`RelayPeerTransport`] — "move these bytes to that runtime." +//! +//! The seams are what keep single-instance deployments and same-pod sessions +//! mesh-free: when `BUZZ_MESH=off` or no peers exist, the relay never +//! constructs a mesh and the in-process fast path is untouched. +//! +//! **The law:** mesh membership is a hint; the Redis fenced generation is the +//! arbiter. Nothing in this crate grants ownership — see [`wire::FencedHeader`]. + +pub mod endpoint; +pub mod gossip; +pub mod membership; +pub mod peer; +pub mod registry; +pub mod runtime; +pub mod status; +pub mod wire; + +// Lane modules — one owner per file (see the mesh thread for lane map): +// endpoint.rs, peer.rs — Mari (transport core) +// registry.rs, gossip.rs, +// membership.rs, status.rs — Max (membership + /_mesh) +// Session directory + tunnel routing live relay-side (Perci), consuming the +// seams below; huddle fan-out lives in buzz-relay's audio module (Dawn). + +use std::future::Future; +use std::pin::Pin; + +use bytes::Bytes; + +pub use gossip::{GossipDigestEntry, GossipMessage, GossipRecord, GossipState, PhiAccrual}; +pub use membership::MeshMembership; +pub use registry::{ReadyHeartbeat, ReadyRecord, ReadyRegistry, RuntimeAttestation}; +pub use runtime::MeshRuntime; +pub use status::{ConnectionState, MeshCounters, MeshPeerCounters, MeshPeerStatus, MeshStatus}; +pub use wire::{ + FencedHeader, GoodbyeReason, MeshDatagram, MeshStreamFrame, Profile, RuntimeId, StreamHello, + StreamRole, ALPN, WIRE_VERSION, +}; + +/// Mesh configuration, resolved from env by the relay. +#[derive(Clone, Debug)] +pub struct MeshConfig { + /// `BUZZ_MESH` — `on` (default when replicas can exist) | `off` kill + /// switch. When off, the relay must behave exactly like single-instance. + pub enabled: bool, + /// UDP bind for the iroh endpoint (`BUZZ_MESH_BIND_ADDR`, default + /// `0.0.0.0:3478`). Excluded from istio sidecar capture in k8s. + pub bind_addr: std::net::SocketAddr, + /// Ready-registry heartbeat refresh (default 15s; expiry is 3x). + pub registry_refresh: std::time::Duration, +} + +#[derive(Debug, thiserror::Error)] +pub enum MeshError { + #[error("frame encode: {0}")] + Encode(#[source] postcard::Error), + #[error("frame decode: {0}")] + Decode(#[source] postcard::Error), + #[error("unknown wire version {0}")] + UnknownWireVersion(u8), + #[error("empty frame")] + EmptyFrame, + #[error("frame exceeds max size ({size} > {max})")] + FrameTooLarge { size: usize, max: usize }, + #[error("datagram exceeds connection max_datagram_size ({size} > {max})")] + DatagramTooLarge { size: usize, max: usize }, + #[error("peer {0} not connected")] + PeerNotConnected(RuntimeId), + #[error("peer {0} is draining")] + PeerDraining(RuntimeId), + #[error("stale generation for session {session_id}: frame {frame_generation} < known {known_generation}")] + StaleGeneration { + session_id: uuid::Uuid, + frame_generation: u64, + known_generation: u64, + }, + // The three variants below complete the fence-rejection taxonomy alongside + // `StaleGeneration` (Wren's chaos-gate ruling: every fence-visible reject + // is a typed variant, never a generic `Transport`, so live kill-9 / + // partition / replay evidence is unambiguous). Counter surface: + // `mesh_fence_rejections_total{reason=...}` with reasons + // `stale_generation` | `no_active_lease` | `owner_mismatch` | + // `future_generation`. None of these are serialized — the wire-level fence + // signal remains `GoodbyeReason::StaleGeneration`. + #[error("no active lease for session {session_id}: frame generation {frame_generation}, known generation {known_generation}, claimed owner {frame_owner_runtime_id}")] + NoActiveLease { + session_id: uuid::Uuid, + frame_generation: u64, + known_generation: u64, + /// The owner the *frame* claimed — there is no current owner by + /// definition when no live lease exists. + frame_owner_runtime_id: RuntimeId, + }, + #[error("owner mismatch for session {session_id} generation {generation}: frame owner {frame_owner_runtime_id} != current owner {current_owner_runtime_id}")] + OwnerMismatch { + session_id: uuid::Uuid, + generation: u64, + frame_owner_runtime_id: RuntimeId, + current_owner_runtime_id: RuntimeId, + }, + #[error("future generation for session {session_id}: frame {frame_generation} > known {known_generation}")] + FutureGeneration { + session_id: uuid::Uuid, + frame_generation: u64, + known_generation: u64, + }, + #[error("mesh is disabled (BUZZ_MESH=off)")] + Disabled, + #[error("transport: {0}")] + Transport(String), + #[error("redis: {0}")] + Redis(#[from] redis::RedisError), +} + +/// A peer as membership sees it. Everything here is a routing HINT. +#[derive(Clone, Debug)] +pub struct PeerInfo { + pub runtime_id: RuntimeId, + pub draining: bool, + /// Phi-accrual suspicion; `None` until enough heartbeats observed. + pub phi: Option, + /// Advisory load factor gossiped by the peer (0.0..). + pub load: f32, +} + +/// Boxed future used across the seam traits. Public because implementors of +/// [`StreamSendHalf`]/[`StreamRecvHalf`]/[`RelayPeerTransport`] outside this +/// crate must name it. +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// Seam 1: membership. Answers "who can I route to?" — never "who owns what." +pub trait RelayMeshMembership: Send + Sync + 'static { + /// Live, non-suspect peers (self excluded). + fn peers(&self) -> Vec; + /// This runtime's mesh identity. + fn local_runtime_id(&self) -> RuntimeId; + /// Begin drain: gossip `draining=true`, stop accepting new sessions. + fn begin_drain(&self); +} + +/// Seam 2: transport. Moves fenced bytes to a specific runtime. +/// +/// Implementations perform the datagram-size and wire-version checks; they do +/// NOT perform generation fencing — that belongs to the session layer on both +/// ends (fencing at every hop means every consumer checks, not the pipe). +pub trait RelayPeerTransport: Send + Sync + 'static { + /// Fire-and-forget realtime datagram (drop-on-full, never blocks on old + /// audio). Errors only for disconnected peer / oversize frame. + fn send_datagram(&self, to: RuntimeId, dgram: MeshDatagram) -> Result<(), MeshError>; + + /// Open a reliable bi-stream to a peer for a session (`ReliableStream` + /// or `HuddleControl` profile). Sends the `Hello` before returning. + fn open_session_stream( + &self, + to: RuntimeId, + hello: StreamHello, + ) -> BoxFuture<'_, Result>; + + /// Register the handler invoked for inbound datagrams / session streams. + /// Called once at relay startup. + fn set_inbound(&self, handler: Box); +} + +/// Inbound mesh traffic, delivered after wire decode + Hello validation. +pub trait InboundHandler: Send + Sync + 'static { + fn on_datagram(&self, from: RuntimeId, dgram: MeshDatagram); + fn on_session_stream(&self, from: RuntimeId, hello: StreamHello, stream: MeshStream); +} + +/// A reliable mesh stream: length-delimited `MeshStreamFrame`s over QUIC. +/// Concrete type (not a trait) so lanes share one framing implementation. +pub struct MeshStream { + // Mari: wrap iroh SendStream/RecvStream with the u32-LE length framing + // from `wire`. Placeholder halves keep the seam compilable pre-transport. + pub(crate) send: Box, + pub(crate) recv: Box, +} + +pub trait StreamSendHalf: Send + 'static { + fn send_frame(&mut self, frame: MeshStreamFrame) -> BoxFuture<'_, Result<(), MeshError>>; + fn finish(&mut self) -> Result<(), MeshError>; +} + +pub trait StreamRecvHalf: Send + 'static { + fn recv_frame(&mut self) -> BoxFuture<'_, Result, MeshError>>; +} + +impl MeshStream { + pub fn send_frame(&mut self, frame: MeshStreamFrame) -> BoxFuture<'_, Result<(), MeshError>> { + self.send.send_frame(frame) + } + pub fn recv_frame(&mut self) -> BoxFuture<'_, Result, MeshError>> { + self.recv.recv_frame() + } + pub fn finish(&mut self) -> Result<(), MeshError> { + self.send.finish() + } +} + +/// Raw bytes helper used by transport internals. +pub fn encode_datagram_checked( + dgram: &MeshDatagram, + max_datagram_size: usize, +) -> Result { + let bytes = wire::encode(dgram)?; + if bytes.len() > max_datagram_size { + return Err(MeshError::DatagramTooLarge { + size: bytes.len(), + max: max_datagram_size, + }); + } + Ok(Bytes::from(bytes)) +} diff --git a/crates/buzz-relay-mesh/src/membership.rs b/crates/buzz-relay-mesh/src/membership.rs new file mode 100644 index 0000000000..9efffb1d80 --- /dev/null +++ b/crates/buzz-relay-mesh/src/membership.rs @@ -0,0 +1,501 @@ +//! In-memory mesh membership table fed by Redis seed records and gossip. +//! +//! This module implements the relay-facing [`RelayMeshMembership`] seam. It is +//! deliberately incapable of electing session owners: peers here are dial/routing +//! hints only, and liveness disagreement never performs takeover. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; +use std::time::SystemTime; + +use crate::gossip::{system_time_from_millis, GossipRecord, PhiAccrual}; +use crate::registry::ReadyRecord; +use crate::status::{ConnectionState, MeshCounters, MeshPeerCounters, MeshPeerStatus, MeshStatus}; +use crate::{PeerInfo, RelayMeshMembership, RuntimeId}; + +pub const DEFAULT_PHI_SUSPECT_THRESHOLD: f64 = 8.0; + +#[derive(Clone, Debug)] +struct PeerState { + record: GossipRecord, + phi: PhiAccrual, + connection_state: ConnectionState, + counters: MeshPeerCounters, +} + +/// Thread-safe membership view consumed by the relay. +#[derive(Clone, Debug)] +pub struct MeshMembership { + local_runtime_id: RuntimeId, + local_record: Arc>, + peers: Arc>>, + draining: Arc, + stale_generation_rejections: Arc, + foreign_relay_rejections: Arc, + /// The relay identity ready records must be attested by. All pods in one + /// deployment share the relay signing key, so a valid seed is one signed + /// by *our* key — "signed by some relay key" is possession, not + /// authorization. `None` (never set) rejects every ready record: the + /// unanchored state is fail-closed, not accept-any. + expected_relay_pubkey: Option, + phi_suspect_threshold: f64, +} + +impl MeshMembership { + pub fn new(local_record: GossipRecord) -> Self { + Self { + local_runtime_id: local_record.runtime_id, + local_record: Arc::new(RwLock::new(local_record)), + peers: Arc::new(RwLock::new(HashMap::new())), + draining: Arc::new(AtomicBool::new(false)), + stale_generation_rejections: Arc::new(AtomicU64::new(0)), + foreign_relay_rejections: Arc::new(AtomicU64::new(0)), + expected_relay_pubkey: None, + phi_suspect_threshold: DEFAULT_PHI_SUSPECT_THRESHOLD, + } + } + + /// Anchor ready-record acceptance to this relay identity (hex pubkey). + /// Without an anchor, [`Self::apply_ready_records`] admits nothing. + pub fn with_expected_relay_pubkey(mut self, pubkey_hex: String) -> Self { + self.expected_relay_pubkey = Some(pubkey_hex); + self + } + + pub fn with_phi_suspect_threshold(mut self, threshold: f64) -> Self { + self.phi_suspect_threshold = threshold; + self + } + + pub fn local_record(&self) -> GossipRecord { + self.local_record + .read() + .expect("local record lock poisoned") + .clone() + } + + /// Apply Redis bootstrap records. Existing gossip records win when they are + /// newer; ready-registry records enter as version 1 hints. + /// + /// A record is admitted only when its `relay_pubkey` matches the expected + /// relay identity AND its attestation signature verifies. Matching first + /// makes the authorization question explicit: a record signed by a key we + /// don't recognize is foreign no matter how valid its signature is. + pub fn apply_ready_records(&self, records: impl IntoIterator) { + for ready in records { + if ready.runtime_id == self.local_runtime_id { + continue; + } + match self.expected_relay_pubkey.as_deref() { + Some(expected) if ready.relay_pubkey == expected => {} + anchor => { + self.foreign_relay_rejections + .fetch_add(1, Ordering::Relaxed); + tracing::warn!( + runtime_id = %ready.runtime_id, + record_relay_pubkey = %ready.relay_pubkey, + anchored = anchor.is_some(), + "mesh membership rejected ready seed not attested by expected relay identity" + ); + continue; + } + } + if let Err(err) = ready.verify_attestation() { + tracing::warn!( + runtime_id = %ready.runtime_id, + %err, + "mesh membership rejected unauthenticated ready seed" + ); + continue; + } + let mut record = + GossipRecord::new(ready.runtime_id, ready.endpoint_addrs, ready.proto_version); + record.capabilities = ready.capabilities; + self.apply_gossip_record(record); + } + } + + /// Apply a gossiped record if it is newer than the local copy. + pub fn apply_gossip_record(&self, record: GossipRecord) -> bool { + if record.runtime_id == self.local_runtime_id { + return false; + } + + let heartbeat = system_time_from_millis(record.heartbeat_millis); + let mut peers = self.peers.write().expect("membership lock poisoned"); + match peers.get_mut(&record.runtime_id) { + Some(peer) if record.version <= peer.record.version => false, + Some(peer) => { + peer.record = record; + peer.connection_state = ConnectionState::Connected; + peer.phi.observe(heartbeat); + true + } + None => { + let mut phi = PhiAccrual::default(); + phi.observe(heartbeat); + peers.insert( + record.runtime_id, + PeerState { + counters: MeshPeerCounters { + runtime_id: record.runtime_id.to_string(), + ..MeshPeerCounters::default() + }, + record, + phi, + connection_state: ConnectionState::Connected, + }, + ); + true + } + } + } + + pub fn mark_connection_state(&self, runtime_id: RuntimeId, state: ConnectionState) { + if let Some(peer) = self + .peers + .write() + .expect("membership lock poisoned") + .get_mut(&runtime_id) + { + peer.connection_state = state; + } + } + + pub fn update_local(&self, update: F) -> GossipRecord + where + F: FnOnce(&mut GossipRecord), + { + let mut local = self + .local_record + .write() + .expect("local record lock poisoned"); + update(&mut local); + local.version = local.version.saturating_add(1); + local.heartbeat_millis = crate::gossip::now_millis(); + local.clone() + } + + pub fn is_draining(&self) -> bool { + self.draining.load(Ordering::Relaxed) + } + + /// Whether `runtime_id` is present in the (attested) peer table. Used by + /// the runtime's accept loop to gate inbound connections — a dialability + /// hint, never an ownership statement. + pub fn has_peer(&self, runtime_id: RuntimeId) -> bool { + self.peers + .read() + .expect("membership lock poisoned") + .contains_key(&runtime_id) + } + + /// All known gossip records (local + peers), for reconcile/dial decisions. + pub fn records(&self) -> Vec { + let mut records: Vec = self + .peers + .read() + .expect("membership lock poisoned") + .values() + .map(|peer| peer.record.clone()) + .collect(); + records.push(self.local_record()); + records + } + + /// Scuttlebutt digest over every record this runtime knows (local + peers). + pub fn digest(&self) -> crate::gossip::GossipMessage { + let mut entries: Vec<_> = self + .records() + .into_iter() + .map(|record| crate::gossip::GossipDigestEntry { + runtime_id: record.runtime_id, + version: record.version, + }) + .collect(); + entries.sort_by_key(|entry| entry.runtime_id.to_hex()); + crate::gossip::GossipMessage::Digest { + version: crate::gossip::GOSSIP_PAYLOAD_VERSION, + entries, + } + } + + /// Records the remote digest is missing or behind on. + pub fn delta_for( + &self, + digest: &[crate::gossip::GossipDigestEntry], + ) -> crate::gossip::GossipMessage { + let remote: std::collections::HashMap<_, _> = digest + .iter() + .map(|entry| (entry.runtime_id, entry.version)) + .collect(); + let mut records: Vec<_> = self + .records() + .into_iter() + .filter(|record| { + remote + .get(&record.runtime_id) + .is_none_or(|version| *version < record.version) + }) + .collect(); + records.sort_by_key(|record| record.runtime_id.to_hex()); + crate::gossip::GossipMessage::Delta { + version: crate::gossip::GOSSIP_PAYLOAD_VERSION, + records, + } + } + + pub fn record_stream_opened(&self, runtime_id: RuntimeId) { + self.update_peer_counters(runtime_id, |c| { + c.streams_opened = c.streams_opened.saturating_add(1) + }); + } + + pub fn record_stream_received(&self, runtime_id: RuntimeId) { + self.update_peer_counters(runtime_id, |c| { + c.streams_received = c.streams_received.saturating_add(1) + }); + } + + pub fn record_datagram_sent(&self, runtime_id: RuntimeId) { + self.update_peer_counters(runtime_id, |c| { + c.datagrams_sent = c.datagrams_sent.saturating_add(1) + }); + } + + pub fn record_datagram_received(&self, runtime_id: RuntimeId) { + self.update_peer_counters(runtime_id, |c| { + c.datagrams_received = c.datagrams_received.saturating_add(1) + }); + } + + pub fn record_gossip_frame_sent(&self, runtime_id: RuntimeId) { + self.update_peer_counters(runtime_id, |c| { + c.gossip_frames_sent = c.gossip_frames_sent.saturating_add(1) + }); + } + + pub fn record_gossip_frame_received(&self, runtime_id: RuntimeId) { + self.update_peer_counters(runtime_id, |c| { + c.gossip_frames_received = c.gossip_frames_received.saturating_add(1) + }); + } + + pub fn record_stale_generation_rejection(&self, runtime_id: Option) { + self.stale_generation_rejections + .fetch_add(1, Ordering::Relaxed); + if let Some(runtime_id) = runtime_id { + self.update_peer_counters(runtime_id, |c| { + c.stale_generation_rejections = c.stale_generation_rejections.saturating_add(1) + }); + } + } + + pub fn status(&self) -> MeshStatus { + let now = SystemTime::now(); + let local = self.local_record(); + let mut peers = self.peer_statuses(now); + peers.sort_by(|a, b| a.runtime_id.cmp(&b.runtime_id)); + let counters = MeshCounters { + stale_generation_rejections: self.stale_generation_rejections.load(Ordering::Relaxed), + foreign_relay_rejections: self.foreign_relay_rejections.load(Ordering::Relaxed), + peers: peers.iter().map(|peer| peer.counters.clone()).collect(), + }; + MeshStatus { + enabled: true, + local_runtime_id: local.runtime_id.to_string(), + draining: self.is_draining(), + peer_count: peers.len(), + peers, + counters, + } + } + + fn update_peer_counters(&self, runtime_id: RuntimeId, update: F) + where + F: FnOnce(&mut MeshPeerCounters), + { + if let Some(peer) = self + .peers + .write() + .expect("membership lock poisoned") + .get_mut(&runtime_id) + { + update(&mut peer.counters); + } + } + + fn peer_statuses(&self, now: SystemTime) -> Vec { + self.peers + .read() + .expect("membership lock poisoned") + .values() + .map(|peer| { + let phi = peer.phi.phi_at(now); + let connection_state = if phi.is_some_and(|p| p >= self.phi_suspect_threshold) { + ConnectionState::Suspect + } else { + peer.connection_state + }; + MeshPeerStatus { + runtime_id: peer.record.runtime_id.to_string(), + endpoint_addrs: peer.record.endpoint_addrs.clone(), + proto_version: peer.record.proto_version, + draining: peer.record.draining, + connection_state, + phi, + load: peer.record.load, + record_version: peer.record.version, + last_heartbeat_millis: peer.record.heartbeat_millis, + counters: peer.counters.clone(), + } + }) + .collect() + } +} + +impl RelayMeshMembership for MeshMembership { + fn peers(&self) -> Vec { + let now = SystemTime::now(); + self.peers + .read() + .expect("membership lock poisoned") + .values() + .filter_map(|peer| { + let phi = peer.phi.phi_at(now); + if phi.is_some_and(|p| p >= self.phi_suspect_threshold) { + return None; + } + Some(PeerInfo { + runtime_id: peer.record.runtime_id, + draining: peer.record.draining, + phi, + load: peer.record.load, + }) + }) + .collect() + } + + fn local_runtime_id(&self) -> RuntimeId { + self.local_runtime_id + } + + fn begin_drain(&self) { + self.draining.store(true, Ordering::Relaxed); + self.update_local(|record| record.draining = true); + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, UNIX_EPOCH}; + + use super::*; + + fn rid(byte: u8) -> RuntimeId { + RuntimeId([byte; 32]) + } + + fn record(byte: u8, version: u64, heartbeat_secs: u64) -> GossipRecord { + GossipRecord { + runtime_id: rid(byte), + endpoint_addrs: vec![format!("127.0.0.{byte}:3478")], + proto_version: 1, + load: 0.25, + draining: false, + capabilities: vec!["reliable-stream".to_string()], + version, + heartbeat_millis: (UNIX_EPOCH + Duration::from_secs(heartbeat_secs)) + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64, + } + } + + fn relay_keys() -> nostr::Keys { + nostr::Keys::generate() + } + + fn ready_record_signed(byte: u8, endpoint_addr: &str, keys: &nostr::Keys) -> ReadyRecord { + ReadyRecord::new(rid(byte), keys, vec![endpoint_addr.into()], 1, vec![]) + } + + #[test] + fn ready_records_seed_peers_but_skip_self() { + let keys = relay_keys(); + let membership = MeshMembership::new(record(1, 1, 1)) + .with_expected_relay_pubkey(keys.public_key().to_hex()); + membership.apply_ready_records([ + ready_record_signed(1, "self", &keys), + ready_record_signed(2, "peer", &keys), + ]); + let peers = membership.peers(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].runtime_id, rid(2)); + } + + #[test] + fn ready_records_must_have_valid_attestation() { + let keys = relay_keys(); + let membership = MeshMembership::new(record(1, 1, 1)) + .with_expected_relay_pubkey(keys.public_key().to_hex()); + let mut tampered = ready_record_signed(2, "peer", &keys); + tampered.runtime_id = rid(3); + tampered.runtime_pubkey = rid(3).to_hex(); + + membership.apply_ready_records([tampered]); + assert!(membership.peers().is_empty()); + } + + #[test] + fn ready_records_from_foreign_relay_identity_are_rejected() { + let ours = relay_keys(); + let theirs = relay_keys(); + let membership = MeshMembership::new(record(1, 1, 1)) + .with_expected_relay_pubkey(ours.public_key().to_hex()); + + // Validly signed, but by a key that isn't our deployment's identity. + membership.apply_ready_records([ready_record_signed(2, "peer", &theirs)]); + assert!(membership.peers().is_empty()); + assert_eq!(membership.status().counters.foreign_relay_rejections, 1); + } + + #[test] + fn unanchored_membership_rejects_all_ready_records() { + let keys = relay_keys(); + let membership = MeshMembership::new(record(1, 1, 1)); + membership.apply_ready_records([ready_record_signed(2, "peer", &keys)]); + assert!(membership.peers().is_empty()); + assert_eq!(membership.status().counters.foreign_relay_rejections, 1); + } + + #[test] + fn stale_gossip_record_is_ignored() { + let membership = MeshMembership::new(record(1, 1, 1)); + assert!(membership.apply_gossip_record(record(2, 5, 1))); + assert!(!membership.apply_gossip_record(record(2, 4, 2))); + assert_eq!(membership.status().peers[0].record_version, 5); + } + + #[test] + fn counters_are_reflected_in_status() { + let membership = MeshMembership::new(record(1, 1, 1)); + membership.apply_gossip_record(record(2, 1, 1)); + membership.record_datagram_sent(rid(2)); + membership.record_stale_generation_rejection(Some(rid(2))); + let status = membership.status(); + assert_eq!(status.counters.stale_generation_rejections, 1); + assert_eq!(status.peers[0].counters.datagrams_sent, 1); + assert_eq!(status.peers[0].counters.stale_generation_rejections, 1); + } + + #[test] + fn begin_drain_updates_local_record() { + let membership = MeshMembership::new(record(1, 1, 1)); + membership.begin_drain(); + assert!(membership.is_draining()); + assert!(membership.local_record().draining); + assert_eq!(membership.local_record().version, 2); + } +} diff --git a/crates/buzz-relay-mesh/src/peer.rs b/crates/buzz-relay-mesh/src/peer.rs new file mode 100644 index 0000000000..59ee8308fc --- /dev/null +++ b/crates/buzz-relay-mesh/src/peer.rs @@ -0,0 +1,202 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use crate::{ + encode_datagram_checked, wire, MeshDatagram, MeshError, MeshStream, MeshStreamFrame, RuntimeId, + StreamRecvHalf, StreamSendHalf, ALPN, +}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PeerCounters { + pub streams_opened: u64, + pub streams_accepted: u64, + pub datagrams_sent: u64, + pub datagrams_received: u64, +} + +#[derive(Debug, Default)] +struct PeerCountersInner { + streams_opened: AtomicU64, + streams_accepted: AtomicU64, + datagrams_sent: AtomicU64, + datagrams_received: AtomicU64, +} + +impl PeerCountersInner { + fn snapshot(&self) -> PeerCounters { + PeerCounters { + streams_opened: self.streams_opened.load(Ordering::Relaxed), + streams_accepted: self.streams_accepted.load(Ordering::Relaxed), + datagrams_sent: self.datagrams_sent.load(Ordering::Relaxed), + datagrams_received: self.datagrams_received.load(Ordering::Relaxed), + } + } +} + +/// Authenticated iroh connection to one peer runtime. +#[derive(Debug, Clone)] +pub struct MeshPeer { + _endpoint: iroh::Endpoint, + conn: iroh::endpoint::Connection, + runtime_id: RuntimeId, + counters: Arc, +} + +impl MeshPeer { + pub(crate) fn from_connection( + endpoint: iroh::Endpoint, + conn: iroh::endpoint::Connection, + ) -> Result { + if conn.alpn() != ALPN { + return Err(MeshError::Transport(format!( + "unexpected mesh ALPN {}", + String::from_utf8_lossy(conn.alpn()) + ))); + } + + Ok(Self { + _endpoint: endpoint, + runtime_id: crate::endpoint::runtime_id_from_public_key(conn.remote_id()), + conn, + counters: Arc::default(), + }) + } + + pub fn runtime_id(&self) -> RuntimeId { + self.runtime_id + } + + pub fn max_datagram_size(&self) -> Option { + self.conn.max_datagram_size() + } + + pub fn counters(&self) -> PeerCounters { + self.counters.snapshot() + } + + pub async fn open_bi(&self) -> Result { + let (send, recv) = self + .conn + .open_bi() + .await + .map_err(|err| MeshError::Transport(err.to_string()))?; + self.counters.streams_opened.fetch_add(1, Ordering::Relaxed); + Ok(MeshStream::new( + Box::new(IrohSendHalf(send)), + Box::new(IrohRecvHalf(recv)), + )) + } + + pub async fn accept_bi(&self) -> Result { + let (send, recv) = self + .conn + .accept_bi() + .await + .map_err(|err| MeshError::Transport(err.to_string()))?; + self.counters + .streams_accepted + .fetch_add(1, Ordering::Relaxed); + Ok(MeshStream::new( + Box::new(IrohSendHalf(send)), + Box::new(IrohRecvHalf(recv)), + )) + } + + pub fn send_datagram(&self, dgram: &MeshDatagram) -> Result<(), MeshError> { + let max = self + .conn + .max_datagram_size() + .ok_or_else(|| MeshError::Transport("peer does not support QUIC datagrams".into()))?; + let bytes = encode_datagram_checked(dgram, max)?; + self.conn + .send_datagram(bytes) + .map_err(|err| MeshError::Transport(err.to_string()))?; + self.counters.datagrams_sent.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + + pub async fn recv_datagram(&self) -> Result { + let bytes = self + .conn + .read_datagram() + .await + .map_err(|err| MeshError::Transport(err.to_string()))?; + let dgram = wire::decode::(&bytes)?; + self.counters + .datagrams_received + .fetch_add(1, Ordering::Relaxed); + Ok(dgram) + } +} + +struct IrohSendHalf(iroh::endpoint::SendStream); +struct IrohRecvHalf(iroh::endpoint::RecvStream); + +impl StreamSendHalf for IrohSendHalf { + fn send_frame( + &mut self, + frame: MeshStreamFrame, + ) -> crate::BoxFuture<'_, Result<(), MeshError>> { + Box::pin(async move { + let bytes = wire::encode(&frame)?; + if bytes.len() > wire::MAX_STREAM_FRAME as usize { + return Err(MeshError::FrameTooLarge { + size: bytes.len(), + max: wire::MAX_STREAM_FRAME as usize, + }); + } + self.0 + .write_all(&(bytes.len() as u32).to_le_bytes()) + .await + .map_err(|err| MeshError::Transport(err.to_string()))?; + self.0 + .write_all(&bytes) + .await + .map_err(|err| MeshError::Transport(err.to_string()))?; + Ok(()) + }) + } + + fn finish(&mut self) -> Result<(), MeshError> { + self.0 + .finish() + .map_err(|err| MeshError::Transport(err.to_string())) + } +} + +impl StreamRecvHalf for IrohRecvHalf { + fn recv_frame(&mut self) -> crate::BoxFuture<'_, Result, MeshError>> { + Box::pin(async move { + let mut len = [0u8; 4]; + match self.0.read_exact(&mut len).await { + Ok(_) => {} + Err(iroh::endpoint::ReadExactError::FinishedEarly(0)) => return Ok(None), + Err(err) => return Err(MeshError::Transport(err.to_string())), + } + + let len = u32::from_le_bytes(len); + if len > wire::MAX_STREAM_FRAME { + return Err(MeshError::FrameTooLarge { + size: len as usize, + max: wire::MAX_STREAM_FRAME as usize, + }); + } + + let mut bytes = vec![0u8; len as usize]; + self.0 + .read_exact(&mut bytes) + .await + .map_err(|err| MeshError::Transport(err.to_string()))?; + wire::decode::(&bytes).map(Some) + }) + } +} + +impl MeshStream { + /// Assemble a stream from framing halves. Public so consumer crates can + /// build in-memory streams over stub halves in tests; production streams + /// only come from the transport (`MeshPeer::open_bi` / accept loop). + pub fn new(send: Box, recv: Box) -> Self { + Self { send, recv } + } +} diff --git a/crates/buzz-relay-mesh/src/registry.rs b/crates/buzz-relay-mesh/src/registry.rs new file mode 100644 index 0000000000..635dff6328 --- /dev/null +++ b/crates/buzz-relay-mesh/src/registry.rs @@ -0,0 +1,385 @@ +//! Redis ready-registry bootstrap for the relay mesh. +//! +//! The registry is only the way into the mesh. Entries are membership hints: +//! they tell a fresh runtime which peer endpoints to dial, but never decide +//! session ownership or takeover. The fenced Redis session directory remains +//! the arbiter for session generations. + +use std::str::FromStr; +use std::time::Duration; + +use nostr::secp256k1::schnorr::Signature; +use nostr::secp256k1::{Message, XOnlyPublicKey}; +use nostr::PublicKey; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{MeshError, RuntimeId}; + +pub const READY_KEY_PREFIX: &str = "mesh:ready:"; +pub const DEFAULT_REGISTRY_REFRESH: Duration = Duration::from_secs(15); +pub const REGISTRY_EXPIRY_MULTIPLIER: u64 = 3; +pub const ATTESTATION_CONTEXT: &str = "buzz-relay-mesh-ready-v1"; + +/// Relay-key-signed binding for a boot-unique runtime endpoint pubkey. +/// +/// The relay public key is the deployment Nostr/secp256k1 identity. It never +/// becomes the mesh runtime id; it only signs this Redis-published binding so +/// peers can reject unauthenticated endpoint ids before dialing/accepting. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeAttestation { + /// Nostr/secp256k1 relay public key, hex encoded. + pub relay_pubkey: String, + /// Schnorr signature by `relay_pubkey` over [`attestation_preimage`]. + pub relay_sig: String, +} + +impl RuntimeAttestation { + pub fn new(relay_keys: &nostr::Keys, runtime_id: RuntimeId) -> Self { + let relay_pubkey = relay_keys.public_key().to_hex(); + let message = attestation_message(runtime_id, &relay_pubkey); + let relay_sig = relay_keys.sign_schnorr(&message).to_string(); + Self { + relay_pubkey, + relay_sig, + } + } + + pub fn verify(&self, runtime_id: RuntimeId) -> Result<(), MeshError> { + verify_attestation(runtime_id, &self.relay_pubkey, &self.relay_sig) + } +} + +fn verify_attestation( + runtime_id: RuntimeId, + relay_pubkey: &str, + relay_sig: &str, +) -> Result<(), MeshError> { + let relay_pubkey = PublicKey::from_hex(relay_pubkey).map_err(|err| { + MeshError::Transport(format!( + "ready registry attestation invalid relay_pubkey: {err}" + )) + })?; + let xonly: XOnlyPublicKey = relay_pubkey.xonly().map_err(|err| { + MeshError::Transport(format!( + "ready registry attestation relay_pubkey xonly conversion failed: {err}" + )) + })?; + let sig = Signature::from_str(relay_sig).map_err(|err| { + MeshError::Transport(format!( + "ready registry attestation invalid relay_sig: {err}" + )) + })?; + let message = attestation_message(runtime_id, &relay_pubkey.to_hex()); + nostr::secp256k1::SECP256K1 + .verify_schnorr(&sig, &message, &xonly) + .map_err(|err| { + MeshError::Transport(format!( + "ready registry attestation signature verification failed: {err}" + )) + }) +} + +/// Stable signed payload. Keep this textual and versioned so transport/relay +/// integration can reproduce it exactly without depending on JSON key order. +pub fn attestation_preimage(runtime_id: RuntimeId, relay_pubkey: &str) -> String { + format!( + "{ATTESTATION_CONTEXT}\nruntime_pubkey={}\nrelay_pubkey={relay_pubkey}", + runtime_id.to_hex() + ) +} + +fn attestation_message(runtime_id: RuntimeId, relay_pubkey: &str) -> Message { + let digest = Sha256::digest(attestation_preimage(runtime_id, relay_pubkey).as_bytes()); + Message::from_digest(digest.into()) +} + +/// Value stored at `mesh:ready:{runtime_id}`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ReadyRecord { + pub runtime_id: RuntimeId, + /// Explicit duplicate of `runtime_id` for the contract record shape: this + /// is the boot-unique ed25519/iroh endpoint pubkey being attested. + pub runtime_pubkey: String, + /// Nostr/secp256k1 relay public key that signs `runtime_pubkey`. + pub relay_pubkey: String, + /// Schnorr signature by `relay_pubkey` over [`attestation_preimage`]. + pub relay_sig: String, + /// Dialable iroh endpoint addresses, serialized as strings so this layer + /// does not depend on transport internals. + pub endpoint_addrs: Vec, + pub proto_version: u16, + pub capabilities: Vec, +} + +impl ReadyRecord { + pub fn new( + runtime_id: RuntimeId, + relay_keys: &nostr::Keys, + endpoint_addrs: Vec, + proto_version: u16, + capabilities: Vec, + ) -> Self { + let attestation = RuntimeAttestation::new(relay_keys, runtime_id); + Self { + runtime_id, + runtime_pubkey: runtime_id.to_hex(), + relay_pubkey: attestation.relay_pubkey, + relay_sig: attestation.relay_sig, + endpoint_addrs, + proto_version, + capabilities, + } + } + + pub fn key(&self) -> String { + ready_key(self.runtime_id) + } + + pub fn verify_attestation(&self) -> Result<(), MeshError> { + if self.runtime_pubkey != self.runtime_id.to_hex() { + return Err(MeshError::Transport(format!( + "ready registry runtime_id/runtime_pubkey mismatch: {} != {}", + self.runtime_id, self.runtime_pubkey + ))); + } + verify_attestation(self.runtime_id, &self.relay_pubkey, &self.relay_sig) + } +} + +pub fn ready_key(runtime_id: RuntimeId) -> String { + format!("{READY_KEY_PREFIX}{runtime_id}") +} + +pub fn expiry_for(refresh: Duration) -> Duration { + refresh.saturating_mul(REGISTRY_EXPIRY_MULTIPLIER as u32) +} + +/// Redis-backed mesh bootstrap registry. +#[derive(Clone)] +pub struct ReadyRegistry { + pool: deadpool_redis::Pool, + refresh: Duration, +} + +impl ReadyRegistry { + pub fn new(pool: deadpool_redis::Pool, refresh: Duration) -> Self { + Self { pool, refresh } + } + + pub fn refresh_interval(&self) -> Duration { + self.refresh + } + + pub fn expiry(&self) -> Duration { + expiry_for(self.refresh) + } + + /// Publish this runtime as ready. Callers MUST only invoke this after the + /// relay would pass readiness (shutdown=false, Postgres reachable, Redis + /// reachable). This method deliberately has no hidden readiness probe so the + /// rule stays explicit at the relay boundary. + pub async fn publish_ready(&self, record: &ReadyRecord) -> Result<(), MeshError> { + record.verify_attestation()?; + let mut conn = self.conn().await?; + let payload = serde_json::to_string(record) + .map_err(|e| MeshError::Transport(format!("ready registry encode: {e}")))?; + let ttl_secs = self.expiry().as_secs().max(1); + redis::cmd("SET") + .arg(record.key()) + .arg(payload) + .arg("EX") + .arg(ttl_secs) + .query_async::<()>(&mut conn) + .await?; + Ok(()) + } + + /// Remove this runtime on clean shutdown. A crash is handled by TTL expiry. + pub async fn clear_ready(&self, runtime_id: RuntimeId) -> Result<(), MeshError> { + let mut conn = self.conn().await?; + redis::cmd("DEL") + .arg(ready_key(runtime_id)) + .query_async::<()>(&mut conn) + .await?; + Ok(()) + } + + /// Scan all ready records. Malformed/stale/unauthenticated values are + /// skipped with a warn: a bad registry entry must not prevent bootstrap + /// from healthy peers. + pub async fn scan_ready(&self) -> Result, MeshError> { + let mut conn = self.conn().await?; + let mut cursor = 0u64; + let mut out = Vec::new(); + + loop { + let (next, keys): (u64, Vec) = redis::cmd("SCAN") + .arg(cursor) + .arg("MATCH") + .arg(format!("{READY_KEY_PREFIX}*")) + .arg("COUNT") + .arg(100u32) + .query_async(&mut conn) + .await?; + + for key in keys { + let raw: Option = + redis::cmd("GET").arg(&key).query_async(&mut conn).await?; + let Some(raw) = raw else { continue }; + match serde_json::from_str::(&raw) { + Ok(record) if record.key() == key => match record.verify_attestation() { + Ok(()) => out.push(record), + Err(err) => tracing::warn!( + key, + runtime_id = %record.runtime_id, + %err, + "mesh ready registry attestation failed — skipping" + ), + }, + Ok(record) => tracing::warn!( + key, + runtime_id = %record.runtime_id, + "mesh ready registry key/runtime mismatch — skipping" + ), + Err(err) => { + tracing::warn!(key, %err, "mesh ready registry decode failed — skipping") + } + } + } + + if next == 0 { + break; + } + cursor = next; + } + + Ok(out) + } + + pub fn heartbeat(&self, record: ReadyRecord) -> ReadyHeartbeat { + ReadyHeartbeat { + registry: self.clone(), + record, + published: false, + } + } + + async fn conn(&self) -> Result { + self.pool + .get() + .await + .map_err(|e| MeshError::Transport(format!("redis pool: {e}"))) + } +} + +/// Readiness-gated registry heartbeat. +/// +/// The relay owns the readiness predicate; this helper owns the edge behavior: +/// publish only while ready, clear on ready→not-ready, and clear on shutdown. +pub struct ReadyHeartbeat { + registry: ReadyRegistry, + record: ReadyRecord, + published: bool, +} + +impl ReadyHeartbeat { + pub fn record(&self) -> &ReadyRecord { + &self.record + } + + pub fn published(&self) -> bool { + self.published + } + + pub async fn tick(&mut self, ready: bool) -> Result<(), MeshError> { + if ready { + self.registry.publish_ready(&self.record).await?; + self.published = true; + } else if self.published { + self.registry.clear_ready(self.record.runtime_id).await?; + self.published = false; + } + Ok(()) + } + + pub async fn shutdown(&mut self) -> Result<(), MeshError> { + if self.published { + self.registry.clear_ready(self.record.runtime_id).await?; + self.published = false; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rid(byte: u8) -> RuntimeId { + RuntimeId([byte; 32]) + } + + fn relay_keys() -> nostr::Keys { + nostr::Keys::generate() + } + + fn ready_record(byte: u8) -> ReadyRecord { + ReadyRecord::new(rid(byte), &relay_keys(), vec![], 1, vec![]) + } + + #[test] + fn ready_key_is_stable_and_namespaced() { + assert_eq!( + ready_key(rid(0xAB)), + format!("mesh:ready:{}", "ab".repeat(32)) + ); + } + + #[test] + fn expiry_is_three_refreshes() { + assert_eq!(expiry_for(Duration::from_secs(15)), Duration::from_secs(45)); + } + + #[test] + fn heartbeat_starts_unpublished() { + let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:6379") + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let registry = ReadyRegistry::new(pool, Duration::from_secs(15)); + let heartbeat = registry.heartbeat(ready_record(1)); + assert!(!heartbeat.published()); + assert_eq!(heartbeat.record().runtime_id, rid(1)); + } + + #[test] + fn ready_record_roundtrips_json() { + let record = ReadyRecord::new( + rid(7), + &relay_keys(), + vec!["127.0.0.1:3478".to_string()], + 1, + vec!["realtime-media".to_string()], + ); + let raw = serde_json::to_string(&record).unwrap(); + assert_eq!(serde_json::from_str::(&raw).unwrap(), record); + } + + #[test] + fn ready_record_attestation_verifies_and_binds_runtime_pubkey() { + let record = ready_record(9); + record.verify_attestation().unwrap(); + + let mut tampered = record.clone(); + tampered.runtime_pubkey = rid(10).to_hex(); + assert!(tampered.verify_attestation().is_err()); + } + + #[test] + fn attestation_rejects_signature_for_other_runtime() { + let mut record = ready_record(11); + record.runtime_id = rid(12); + record.runtime_pubkey = rid(12).to_hex(); + assert!(record.verify_attestation().is_err()); + } +} diff --git a/crates/buzz-relay-mesh/src/runtime.rs b/crates/buzz-relay-mesh/src/runtime.rs new file mode 100644 index 0000000000..897de6ca8c --- /dev/null +++ b/crates/buzz-relay-mesh/src/runtime.rs @@ -0,0 +1,896 @@ +//! The live mesh runtime: warm peer manager, accept/dial loops, gossip +//! exchange, and the concrete [`RelayPeerTransport`] implementation. +//! +//! This is the piece that turns the lane modules into a running mesh: +//! +//! - [`MeshRuntime::start`] binds nothing itself — it takes an already-bound +//! [`MeshEndpoint`] plus a [`MeshMembership`] table and spawns the loops. +//! - **Accept loop**: inbound connections are admitted only when the remote +//! runtime id is present in the (attested) membership table; unknown ids get +//! one registry rescan before rejection. Membership is a hint — admission +//! here gates *dialability*, never session ownership. +//! - **Reconcile loop**: periodically rescans the Redis ready registry and +//! dials every known, non-draining peer we are not yet connected to. This is +//! what makes the mesh *warm*: failover is "next frame goes elsewhere," not +//! "wait for a handshake." +//! - **Control stream**: exactly one per peer connection, opened by the +//! dialer. Carries scuttlebutt gossip (`Digest` → `Delta`) both ways. +//! - **Simultaneous dial tie-break**: the connection dialed by the smaller +//! runtime id wins; the loser is dropped. Deterministic on both ends. +//! +//! The fencing law holds here too: nothing in this file consults or mutates +//! session ownership. Transport moves fenced bytes; the session layer on both +//! ends validates the fence against Redis. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::endpoint::{direct_addr, MeshEndpoint}; +use crate::gossip::{decode_message, encode_message, GossipMessage}; +use crate::membership::MeshMembership; +use crate::peer::MeshPeer; +use crate::registry::{ReadyRecord, ReadyRegistry}; +use crate::status::ConnectionState; +use crate::wire::{MeshStreamFrame, StreamHello, StreamRole}; +use crate::{InboundHandler, MeshDatagram, MeshError, MeshStream, RelayPeerTransport, RuntimeId}; + +/// How often the reconcile loop rescans the registry and dials missing peers. +pub const DEFAULT_RECONCILE_INTERVAL: Duration = Duration::from_secs(5); +/// How often each side sends a gossip digest on every control stream. +pub const DEFAULT_GOSSIP_INTERVAL: Duration = Duration::from_secs(2); +/// Bound on queued control-stream frames per peer before backpressure. +const CONTROL_QUEUE_DEPTH: usize = 64; + +struct PeerEntry { + peer: MeshPeer, + /// Writer queue for the peer's control stream. Present once the control + /// stream is up (dialer opens it; acceptor receives it). + control_tx: Option>, + tasks: Vec>, +} + +impl PeerEntry { + fn abort(&self) { + for task in &self.tasks { + task.abort(); + } + } +} + +struct Inner { + endpoint: MeshEndpoint, + membership: MeshMembership, + registry: Option, + peers: RwLock>, + handler: Mutex>>, + gossip_interval: Duration, + reconcile_interval: Duration, +} + +/// Handle to the running mesh. Cheap to clone; dropping all clones does NOT +/// stop the loops — call [`MeshRuntime::shutdown`] for that. +#[derive(Clone)] +pub struct MeshRuntime { + inner: Arc, + loops: Arc>>>, +} + +impl MeshRuntime { + /// Spawn the mesh loops over an already-bound endpoint. + /// + /// `registry` is `None` in tests / single-instance shapes: the reconcile + /// loop then dials from the membership table alone (seeded by gossip or + /// test setup) and skips registry rescans. + pub fn start( + endpoint: MeshEndpoint, + membership: MeshMembership, + registry: Option, + ) -> Self { + Self::start_with_intervals( + endpoint, + membership, + registry, + DEFAULT_GOSSIP_INTERVAL, + DEFAULT_RECONCILE_INTERVAL, + ) + } + + pub fn start_with_intervals( + endpoint: MeshEndpoint, + membership: MeshMembership, + registry: Option, + gossip_interval: Duration, + reconcile_interval: Duration, + ) -> Self { + let inner = Arc::new(Inner { + endpoint, + membership, + registry, + peers: RwLock::new(HashMap::new()), + handler: Mutex::new(None), + gossip_interval, + reconcile_interval, + }); + + let accept = tokio::spawn(accept_loop(Arc::clone(&inner))); + let reconcile = tokio::spawn(reconcile_loop(Arc::clone(&inner))); + let gossip = tokio::spawn(gossip_tick_loop(Arc::clone(&inner))); + + Self { + inner, + loops: Arc::new(Mutex::new(vec![accept, reconcile, gossip])), + } + } + + pub fn membership(&self) -> &MeshMembership { + &self.inner.membership + } + + pub fn local_runtime_id(&self) -> RuntimeId { + self.inner.endpoint.runtime_id() + } + + /// Currently connected peer ids (either direction). + pub fn connected_peers(&self) -> Vec { + self.inner + .peers + .read() + .expect("peer lock poisoned") + .keys() + .copied() + .collect() + } + + /// Force one reconcile pass right now (bootstrap fast-path: dial the seed + /// records without waiting for the first interval tick). + pub async fn reconcile_now(&self) { + reconcile_once(&self.inner).await; + } + + /// Stop all loops and drop all peer connections. + pub fn shutdown(&self) { + for task in self.loops.lock().expect("loop lock poisoned").drain(..) { + task.abort(); + } + let mut peers = self.inner.peers.write().expect("peer lock poisoned"); + for (_, entry) in peers.drain() { + entry.abort(); + } + } +} + +impl RelayPeerTransport for MeshRuntime { + fn send_datagram(&self, to: RuntimeId, dgram: MeshDatagram) -> Result<(), MeshError> { + let peers = self.inner.peers.read().expect("peer lock poisoned"); + let entry = peers.get(&to).ok_or(MeshError::PeerNotConnected(to))?; + entry.peer.send_datagram(&dgram)?; + drop(peers); + self.inner.membership.record_datagram_sent(to); + Ok(()) + } + + fn open_session_stream( + &self, + to: RuntimeId, + hello: StreamHello, + ) -> crate::BoxFuture<'_, Result> { + Box::pin(async move { + let peer = { + let peers = self.inner.peers.read().expect("peer lock poisoned"); + peers + .get(&to) + .map(|entry| entry.peer.clone()) + .ok_or(MeshError::PeerNotConnected(to))? + }; + let mut stream = peer.open_bi().await?; + stream.send_frame(MeshStreamFrame::Hello(hello)).await?; + self.inner.membership.record_stream_opened(to); + Ok(stream) + }) + } + + fn set_inbound(&self, handler: Box) { + *self.inner.handler.lock().expect("handler lock poisoned") = Some(Arc::from(handler)); + } +} + +fn inbound_handler(inner: &Inner) -> Option> { + inner.handler.lock().expect("handler lock poisoned").clone() +} + +/// Simultaneous-dial tie-break: the connection dialed by the smaller runtime +/// id wins. Returns true when the NEW connection should replace the existing. +fn new_connection_wins(local: RuntimeId, remote: RuntimeId, new_dialed_by_us: bool) -> bool { + if local.0 < remote.0 { + // We are the canonical dialer: our outbound connection wins. + new_dialed_by_us + } else { + // The peer is the canonical dialer: their inbound connection wins. + !new_dialed_by_us + } +} + +/// Install a connected peer, spawning its datagram + stream accept loops. +/// Returns false when an existing connection won the tie-break. +fn install_peer(inner: &Arc, peer: MeshPeer, dialed_by_us: bool) -> bool { + let remote = peer.runtime_id(); + let local = inner.endpoint.runtime_id(); + let mut peers = inner.peers.write().expect("peer lock poisoned"); + + if let Some(existing) = peers.get(&remote) { + if !new_connection_wins(local, remote, dialed_by_us) { + tracing::debug!(peer = %remote, "mesh: kept existing connection (tie-break)"); + return false; + } + existing.abort(); + } + + let mut tasks = vec![ + tokio::spawn(datagram_recv_loop(Arc::clone(inner), peer.clone())), + tokio::spawn(stream_accept_loop(Arc::clone(inner), peer.clone())), + ]; + + // Dialer opens the control stream for the connection. + let control_tx = if dialed_by_us { + let (tx, rx) = mpsc::channel(CONTROL_QUEUE_DEPTH); + tasks.push(tokio::spawn(open_control_stream( + Arc::clone(inner), + peer.clone(), + rx, + ))); + Some(tx) + } else { + None + }; + + peers.insert( + remote, + PeerEntry { + peer, + control_tx, + tasks, + }, + ); + drop(peers); + inner + .membership + .mark_connection_state(remote, ConnectionState::Connected); + tracing::info!(peer = %remote, dialed_by_us, "mesh: peer connected"); + true +} + +fn remove_peer(inner: &Inner, runtime_id: RuntimeId) { + if let Some(entry) = inner + .peers + .write() + .expect("peer lock poisoned") + .remove(&runtime_id) + { + entry.abort(); + inner + .membership + .mark_connection_state(runtime_id, ConnectionState::Disconnected); + tracing::info!(peer = %runtime_id, "mesh: peer disconnected"); + } +} + +async fn accept_loop(inner: Arc) { + loop { + match inner.endpoint.accept().await { + Ok(Some(peer)) => { + let remote = peer.runtime_id(); + if !is_known_peer(&inner, remote).await { + tracing::warn!( + peer = %remote, + "mesh: rejected inbound connection from unattested runtime id" + ); + continue; + } + install_peer(&inner, peer, false); + } + Ok(None) => { + tracing::info!("mesh: endpoint closed, accept loop exiting"); + return; + } + Err(err) => { + tracing::warn!(%err, "mesh: inbound connection failed"); + } + } + } +} + +/// Admission check for inbound connections: the runtime id must appear in the +/// attested membership table. Unknown ids get one registry rescan (covers the +/// bootstrap race where a fresh pod dials us before our next reconcile tick). +async fn is_known_peer(inner: &Arc, runtime_id: RuntimeId) -> bool { + if inner.membership.has_peer(runtime_id) { + return true; + } + if let Some(registry) = &inner.registry { + match registry.scan_ready().await { + Ok(records) => inner.membership.apply_ready_records(records), + Err(err) => tracing::warn!(%err, "mesh: registry rescan on inbound failed"), + } + } + inner.membership.has_peer(runtime_id) +} + +async fn reconcile_loop(inner: Arc) { + loop { + reconcile_once(&inner).await; + tokio::time::sleep(inner.reconcile_interval).await; + } +} + +async fn reconcile_once(inner: &Arc) { + if let Some(registry) = &inner.registry { + match registry.scan_ready().await { + Ok(records) => inner.membership.apply_ready_records(records), + Err(err) => tracing::warn!(%err, "mesh: registry scan failed"), + } + } + + let local = inner.endpoint.runtime_id(); + let candidates: Vec<_> = inner + .membership + .records() + .into_iter() + .filter(|record| record.runtime_id != local && !record.draining) + .collect(); + + for record in candidates { + let already_connected = inner + .peers + .read() + .expect("peer lock poisoned") + .contains_key(&record.runtime_id); + if already_connected { + continue; + } + dial_peer(inner, &record).await; + } +} + +async fn dial_peer(inner: &Arc, record: &crate::gossip::GossipRecord) { + for addr in &record.endpoint_addrs { + let sock = match addr.parse() { + Ok(sock) => sock, + Err(err) => { + tracing::warn!(peer = %record.runtime_id, addr, %err, "mesh: bad peer addr"); + continue; + } + }; + let endpoint_addr = match direct_addr(record.runtime_id, sock) { + Ok(ea) => ea, + Err(err) => { + tracing::warn!(peer = %record.runtime_id, %err, "mesh: bad peer id"); + return; + } + }; + inner + .membership + .mark_connection_state(record.runtime_id, ConnectionState::Connecting); + match inner.endpoint.connect(endpoint_addr).await { + Ok(peer) => { + install_peer(inner, peer, true); + return; + } + Err(err) => { + tracing::warn!(peer = %record.runtime_id, addr, %err, "mesh: dial failed"); + } + } + } + inner + .membership + .mark_connection_state(record.runtime_id, ConnectionState::Disconnected); +} + +async fn datagram_recv_loop(inner: Arc, peer: MeshPeer) { + let remote = peer.runtime_id(); + loop { + match peer.recv_datagram().await { + Ok(dgram) => { + inner.membership.record_datagram_received(remote); + if let Some(handler) = inbound_handler(&inner) { + handler.on_datagram(remote, dgram); + } + } + Err(err) => { + tracing::debug!(peer = %remote, %err, "mesh: datagram loop ended"); + remove_peer(&inner, remote); + return; + } + } + } +} + +async fn stream_accept_loop(inner: Arc, peer: MeshPeer) { + let remote = peer.runtime_id(); + loop { + let mut stream = match peer.accept_bi().await { + Ok(stream) => stream, + Err(err) => { + tracing::debug!(peer = %remote, %err, "mesh: stream accept loop ended"); + remove_peer(&inner, remote); + return; + } + }; + + // The first frame on any stream MUST be Hello (wire contract). + let hello = match stream.recv_frame().await { + Ok(Some(MeshStreamFrame::Hello(hello))) => hello, + Ok(other) => { + tracing::warn!(peer = %remote, ?other, "mesh: stream without Hello — dropped"); + continue; + } + Err(err) => { + tracing::warn!(peer = %remote, %err, "mesh: stream Hello read failed"); + continue; + } + }; + + match hello.role { + StreamRole::Control => { + // Acceptor side of the per-connection control stream: register + // a writer queue and start the gossip exchange. + let (tx, rx) = mpsc::channel(CONTROL_QUEUE_DEPTH); + if let Some(entry) = inner + .peers + .write() + .expect("peer lock poisoned") + .get_mut(&remote) + { + entry.control_tx = Some(tx); + } + tokio::spawn(control_stream_exchange( + Arc::clone(&inner), + remote, + stream, + rx, + )); + } + StreamRole::Session { .. } => { + inner.membership.record_stream_received(remote); + if let Some(handler) = inbound_handler(&inner) { + handler.on_session_stream(remote, hello, stream); + } else { + tracing::warn!( + peer = %remote, + "mesh: session stream arrived before inbound handler was set — dropped" + ); + } + } + } + } +} + +/// Dialer side: open the control stream, send Hello{Control}, then exchange. +async fn open_control_stream( + inner: Arc, + peer: MeshPeer, + rx: mpsc::Receiver, +) { + let remote = peer.runtime_id(); + let mut stream = match peer.open_bi().await { + Ok(stream) => stream, + Err(err) => { + tracing::warn!(peer = %remote, %err, "mesh: control stream open failed"); + return; + } + }; + let hello = MeshStreamFrame::Hello(StreamHello { + sender: inner.endpoint.runtime_id(), + role: StreamRole::Control, + }); + if let Err(err) = stream.send_frame(hello).await { + tracing::warn!(peer = %remote, %err, "mesh: control Hello send failed"); + return; + } + control_stream_exchange(inner, remote, stream, rx).await; +} + +/// Both sides: pump queued outbound frames and dispatch inbound gossip. +/// +/// Scuttlebutt: a received `Digest` is answered with a `Delta` of records the +/// digest is missing/behind on; a received `Delta` is applied to membership. +async fn control_stream_exchange( + inner: Arc, + remote: RuntimeId, + stream: MeshStream, + mut rx: mpsc::Receiver, +) { + let MeshStream { mut send, mut recv } = stream; + + let send_inner = Arc::clone(&inner); + let send_task = tokio::spawn(async move { + while let Some(frame) = rx.recv().await { + if let Err(err) = send.send_frame(frame).await { + tracing::debug!(peer = %remote, %err, "mesh: control send ended"); + return; + } + send_inner.membership.record_gossip_frame_sent(remote); + } + }); + + loop { + match recv.recv_frame().await { + Ok(Some(MeshStreamFrame::Gossip { payload })) => { + inner.membership.record_gossip_frame_received(remote); + match decode_message(&payload) { + Ok(GossipMessage::Digest { entries, .. }) => { + let delta = inner.membership.delta_for(&entries); + if let Ok(payload) = encode_message(&delta) { + send_control_frame(&inner, remote, MeshStreamFrame::Gossip { payload }); + } + } + Ok(GossipMessage::Delta { records, .. }) => { + for record in records { + inner.membership.apply_gossip_record(record); + } + } + Err(err) => { + tracing::warn!(peer = %remote, %err, "mesh: bad gossip payload"); + } + } + } + Ok(Some(other)) => { + tracing::warn!(peer = %remote, ?other, "mesh: non-gossip frame on control stream"); + } + Ok(None) | Err(_) => { + tracing::debug!(peer = %remote, "mesh: control stream closed"); + send_task.abort(); + return; + } + } + } +} + +fn send_control_frame(inner: &Inner, remote: RuntimeId, frame: MeshStreamFrame) { + let peers = inner.peers.read().expect("peer lock poisoned"); + if let Some(tx) = peers.get(&remote).and_then(|e| e.control_tx.as_ref()) { + // try_send: gossip is periodic and idempotent — dropping a frame under + // backpressure is strictly better than blocking a recv loop. + let _ = tx.try_send(frame); + } +} + +/// Periodic gossip: refresh the local heartbeat and send a digest on every +/// control stream. Deltas flow back per the exchange loop. +async fn gossip_tick_loop(inner: Arc) { + loop { + tokio::time::sleep(inner.gossip_interval).await; + // Heartbeat: bump the local record so peers' phi accrual sees life. + inner.membership.update_local(|_| {}); + let digest = inner.membership.digest(); + let Ok(payload) = encode_message(&digest) else { + continue; + }; + let targets: Vec = { + let peers = inner.peers.read().expect("peer lock poisoned"); + peers + .iter() + .filter(|(_, e)| e.control_tx.is_some()) + .map(|(id, _)| *id) + .collect() + }; + for remote in targets { + send_control_frame( + &inner, + remote, + MeshStreamFrame::Gossip { + payload: payload.clone(), + }, + ); + } + } +} + +/// Readiness-gated registry heartbeat loop, spawned by the relay after boot. +/// `ready` is the relay-owned readiness predicate (shutdown flag et al.). +pub fn spawn_registry_heartbeat( + registry: ReadyRegistry, + record: ReadyRecord, + ready: Arc bool + Send + Sync>, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut heartbeat = registry.heartbeat(record); + let interval = registry.refresh_interval(); + loop { + if let Err(err) = heartbeat.tick(ready()).await { + tracing::warn!(%err, "mesh: registry heartbeat tick failed"); + } + tokio::time::sleep(interval).await; + } + }) +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Mutex as StdMutex; + use std::time::Duration; + + use iroh::SecretKey; + use tokio::time::timeout; + use uuid::Uuid; + + use super::*; + use crate::gossip::GossipRecord; + use crate::wire::{FencedHeader, Profile}; + + fn loopback_any() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0) + } + + async fn runtime(key_byte: u8) -> (MeshRuntime, Vec) { + let endpoint = MeshEndpoint::bind_with_secret_key( + SecretKey::from_bytes(&[key_byte; 32]), + loopback_any(), + ) + .await + .unwrap(); + let addrs: Vec = endpoint + .addr() + .addrs + .iter() + .filter_map(|ta| match ta { + iroh::TransportAddr::Ip(sock) if sock.ip().is_loopback() => Some(sock.to_string()), + _ => None, + }) + .collect(); + assert!(!addrs.is_empty(), "endpoint must expose a loopback addr"); + let record = GossipRecord::new(endpoint.runtime_id(), addrs.clone(), 1); + let membership = MeshMembership::new(record); + let rt = MeshRuntime::start_with_intervals( + endpoint, + membership, + None, + Duration::from_millis(100), + Duration::from_millis(200), + ); + (rt, addrs) + } + + /// Seed b's record into a's membership so a dials b. + fn seed(a: &MeshRuntime, b: &MeshRuntime, b_addrs: &[String]) { + a.membership().apply_gossip_record(GossipRecord::new( + b.local_runtime_id(), + b_addrs.to_vec(), + 1, + )); + } + + async fn connected_pair() -> (MeshRuntime, MeshRuntime) { + let (a, a_addrs) = runtime(1).await; + let (b, b_addrs) = runtime(2).await; + // Both directions: with no registry to rescan, the acceptor's + // admission gate requires the dialer to already be in its membership + // table (production gets this from the attested ready registry). + seed(&a, &b, &b_addrs); + seed(&b, &a, &a_addrs); + a.reconcile_now().await; + // Wait for both sides to see the connection. + timeout(Duration::from_secs(5), async { + loop { + if a.connected_peers().contains(&b.local_runtime_id()) + && b.connected_peers().contains(&a.local_runtime_id()) + { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("mesh pair should connect"); + (a, b) + } + + struct RecordingHandler { + datagrams: StdMutex>, + streams: StdMutex>, + } + + impl RecordingHandler { + fn new() -> Arc { + Arc::new(Self { + datagrams: StdMutex::new(Vec::new()), + streams: StdMutex::new(Vec::new()), + }) + } + } + + impl InboundHandler for Arc { + fn on_datagram(&self, from: RuntimeId, dgram: MeshDatagram) { + self.datagrams.lock().unwrap().push((from, dgram)); + } + fn on_session_stream(&self, from: RuntimeId, hello: StreamHello, _stream: MeshStream) { + self.streams.lock().unwrap().push((from, hello)); + } + } + + fn fenced(owner: RuntimeId) -> FencedHeader { + FencedHeader { + session_id: Uuid::from_u128(0xFEED), + generation: 3, + owner_runtime_id: owner, + } + } + + #[tokio::test] + async fn warm_pair_connects_and_gossips_membership() { + let (a, b) = connected_pair().await; + // Gossip heartbeats should keep flowing; wait for a to see a gossiped + // (version > 1) record from b. + timeout(Duration::from_secs(5), async { + loop { + let seen = a + .membership() + .records() + .into_iter() + .find(|r| r.runtime_id == b.local_runtime_id()); + if seen.is_some_and(|r| r.version > 1) { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("gossip should refresh b's record on a"); + a.shutdown(); + b.shutdown(); + } + + #[tokio::test] + async fn transport_datagram_reaches_inbound_handler() { + let (a, b) = connected_pair().await; + let handler = RecordingHandler::new(); + b.set_inbound(Box::new(Arc::clone(&handler))); + + let dgram = MeshDatagram { + fenced: fenced(b.local_runtime_id()), + seq: 7, + payload: vec![1, 2, 3], + }; + a.send_datagram(b.local_runtime_id(), dgram.clone()) + .unwrap(); + + timeout(Duration::from_secs(5), async { + loop { + if !handler.datagrams.lock().unwrap().is_empty() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("datagram should arrive"); + let got = handler.datagrams.lock().unwrap(); + assert_eq!(got[0].0, a.local_runtime_id()); + assert_eq!(got[0].1, dgram); + drop(got); + a.shutdown(); + b.shutdown(); + } + + #[tokio::test] + async fn transport_session_stream_reaches_inbound_handler() { + let (a, b) = connected_pair().await; + let handler = RecordingHandler::new(); + b.set_inbound(Box::new(Arc::clone(&handler))); + + let hello = StreamHello { + sender: a.local_runtime_id(), + role: StreamRole::Session { + fenced: fenced(b.local_runtime_id()), + profile: Profile::ReliableStream, + }, + }; + let mut stream = a + .open_session_stream(b.local_runtime_id(), hello.clone()) + .await + .unwrap(); + stream + .send_frame(MeshStreamFrame::Data { + fenced: fenced(b.local_runtime_id()), + payload: b"tunnel".to_vec(), + }) + .await + .unwrap(); + + timeout(Duration::from_secs(5), async { + loop { + if !handler.streams.lock().unwrap().is_empty() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("session stream should arrive"); + let got = handler.streams.lock().unwrap(); + assert_eq!(got[0].0, a.local_runtime_id()); + assert_eq!(got[0].1, hello); + drop(got); + a.shutdown(); + b.shutdown(); + } + + #[tokio::test] + async fn send_to_unconnected_peer_is_typed_error() { + let (a, _addrs) = runtime(9).await; + let ghost = RuntimeId([42u8; 32]); + let err = a + .send_datagram( + ghost, + MeshDatagram { + fenced: fenced(ghost), + seq: 0, + payload: vec![], + }, + ) + .unwrap_err(); + assert!(matches!(err, MeshError::PeerNotConnected(id) if id == ghost)); + a.shutdown(); + } + + #[tokio::test] + async fn simultaneous_dial_converges_to_one_connection() { + let (a, a_addrs) = runtime(3).await; + let (b, b_addrs) = runtime(4).await; + seed(&a, &b, &b_addrs); + seed(&b, &a, &a_addrs); + // Both dial at once. + tokio::join!(a.reconcile_now(), b.reconcile_now()); + + timeout(Duration::from_secs(5), async { + loop { + if a.connected_peers().contains(&b.local_runtime_id()) + && b.connected_peers().contains(&a.local_runtime_id()) + { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("simultaneous dial should converge"); + // Datagrams still flow after the tie-break. + let handler = RecordingHandler::new(); + b.set_inbound(Box::new(Arc::clone(&handler))); + // The surviving connection may need a beat to settle. + timeout(Duration::from_secs(5), async { + loop { + let dgram = MeshDatagram { + fenced: fenced(b.local_runtime_id()), + seq: 1, + payload: vec![9], + }; + let _ = a.send_datagram(b.local_runtime_id(), dgram); + if !handler.datagrams.lock().unwrap().is_empty() { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("datagram should flow after tie-break"); + a.shutdown(); + b.shutdown(); + } + + #[test] + fn tie_break_is_symmetric() { + let small = RuntimeId([1u8; 32]); + let large = RuntimeId([2u8; 32]); + // small dials large: small's outbound wins, large's inbound wins. + assert!(new_connection_wins(small, large, true)); + assert!(new_connection_wins(large, small, false)); + // large dials small: loses on both ends. + assert!(!new_connection_wins(large, small, true)); + assert!(!new_connection_wins(small, large, false)); + } +} diff --git a/crates/buzz-relay-mesh/src/status.rs b/crates/buzz-relay-mesh/src/status.rs new file mode 100644 index 0000000000..895cb7bea5 --- /dev/null +++ b/crates/buzz-relay-mesh/src/status.rs @@ -0,0 +1,60 @@ +//! `/_mesh` status data model. +//! +//! The relay's axum handler can serialize [`MeshStatus`] directly as JSON. + +use serde::Serialize; + +#[derive(Clone, Debug, Default, Serialize)] +pub struct MeshStatus { + pub enabled: bool, + pub local_runtime_id: String, + pub draining: bool, + pub peer_count: usize, + pub peers: Vec, + pub counters: MeshCounters, +} + +#[derive(Clone, Debug, Serialize)] +pub struct MeshPeerStatus { + pub runtime_id: String, + pub endpoint_addrs: Vec, + pub proto_version: u16, + pub draining: bool, + pub connection_state: ConnectionState, + pub phi: Option, + pub load: f32, + pub record_version: u64, + pub last_heartbeat_millis: u64, + pub counters: MeshPeerCounters, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionState { + #[default] + Disconnected, + Connecting, + Connected, + Suspect, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct MeshCounters { + pub stale_generation_rejections: u64, + /// Ready-registry seeds rejected because their `relay_pubkey` did not + /// match this deployment's relay identity (or no anchor was configured). + pub foreign_relay_rejections: u64, + pub peers: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct MeshPeerCounters { + pub runtime_id: String, + pub streams_opened: u64, + pub streams_received: u64, + pub datagrams_sent: u64, + pub datagrams_received: u64, + pub gossip_frames_sent: u64, + pub gossip_frames_received: u64, + pub stale_generation_rejections: u64, +} diff --git a/crates/buzz-relay-mesh/src/wire.rs b/crates/buzz-relay-mesh/src/wire.rs new file mode 100644 index 0000000000..73c8b56925 --- /dev/null +++ b/crates/buzz-relay-mesh/src/wire.rs @@ -0,0 +1,274 @@ +//! The mesh wire contract — FROZEN surface. +//! +//! Every byte that crosses the mesh is one of the frames in this module, +//! postcard-encoded behind a one-byte protocol version. This file is the +//! contract between all mesh lanes: transport (endpoint/peer), membership +//! (gossip/registry), the session directory, and the media fan-out all build +//! against these types. **Changes here require a post in the mesh thread +//! before the edit** — two lanes compiling against different frame layouts is +//! the failure mode this file exists to prevent. +//! +//! ## The fencing law (non-negotiable) +//! +//! Every session-bearing frame carries the fenced tuple +//! [`FencedHeader`] `{session_id, generation, owner_runtime_id}`. Receivers +//! MUST reject frames whose generation is stale for that session, at every +//! hop. Mesh membership is a hint; the fenced generation (Redis CAS lease) +//! is the arbiter. The mesh may say "don't dial" — it may never say "take +//! over." +//! +//! ## Framing +//! +//! - **Datagrams** (realtime-media): one [`MeshDatagram`] per QUIC datagram, +//! postcard-encoded, no length prefix (the datagram boundary is the frame +//! boundary). Senders MUST check the encoded size against the connection's +//! `max_datagram_size()` and fail loud, never truncate. +//! - **Bi-streams** (reliable-stream + gossip control): length-delimited +//! postcard. Each frame is a u32-LE length followed by that many bytes of +//! postcard-encoded [`MeshStreamFrame`]. Max frame size: [`MAX_STREAM_FRAME`]. +//! The first frame on any stream MUST be `Hello`; a non-`Hello` first frame +//! is a protocol error and the stream is reset. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// ALPN for the mesh QUIC endpoint. Version bumps get a new ALPN so old and +/// new pods never half-speak to each other during a rolling deploy. +pub const ALPN: &[u8] = b"buzz/mesh/1"; + +/// Wire protocol version, first byte of every encoded frame (datagram or +/// stream frame). Receivers MUST reject unknown versions loudly (count it, +/// log it) rather than guessing. +pub const WIRE_VERSION: u8 = 1; + +/// Hard cap on a single length-delimited stream frame (16 MiB). Anything +/// larger is a protocol error, not a bigger buffer. +pub const MAX_STREAM_FRAME: u32 = 16 * 1024 * 1024; + +/// A relay runtime's mesh identity: the ed25519 public key of the **mesh +/// endpoint keypair generated fresh at process start**. This is both the +/// iroh endpoint id and the boot-unique runtime id used in the ready +/// registry and ownership leases — one value, boot-unique by construction. +/// +/// It is deliberately NOT the deployment's Nostr relay key: that key is +/// secp256k1, and the helm chart shares one `BUZZ_RELAY_PRIVATE_KEY` Secret +/// across all pods of a release — using it here would give every pod the +/// same runtime id and collapse the ownership plane (Wren's contract-review +/// blocker). Binding to the deployment identity is done out-of-band: the +/// ready-registry record carries a relay-key-signed attestation of the +/// runtime pubkey (membership lane), and peers accept mesh connections only +/// from endpoint ids present in attested registry/gossip records. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RuntimeId(pub [u8; 32]); + +impl RuntimeId { + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } +} + +impl std::fmt::Debug for RuntimeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RuntimeId({}…)", &self.to_hex()[..8]) + } +} + +impl std::fmt::Display for RuntimeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.to_hex()) + } +} + +/// The fenced tuple. Present on every session-bearing frame; checked at +/// every hop against the Redis lease. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FencedHeader { + pub session_id: Uuid, + /// Monotonic lease generation from the Redis CAS. A receiver that has + /// observed generation G for a session rejects any frame with < G. + pub generation: u64, + /// The runtime the sender believes owns the session. Advisory for + /// routing/diagnostics; the generation is what fences. + pub owner_runtime_id: RuntimeId, +} + +/// Tunnel profile, fixed at session establishment. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Profile { + /// Ordered, reliable, backpressured (goose/berd). Rides `open_bi()`. + ReliableStream, + /// Lossy-by-design realtime media (huddle Opus). Rides QUIC datagrams. + RealtimeMedia, + /// Huddle roster/join/leave control. State-bearing — a dropped roster + /// delta is an unrecoverable peer-index desync, so this rides a reliable + /// stream like `ReliableStream`, never datagrams. Separate variant so + /// routing intent and `/_mesh` counters stay legible. + HuddleControl, +} + +/// One QUIC datagram: realtime media only. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MeshDatagram { + pub fenced: FencedHeader, + /// Sender-scoped monotonic sequence for loss/reorder observability. + /// Receivers tolerate gaps and reordering; they never wait. + pub seq: u64, + /// Opaque at this layer: the profile owner defines the internal layout. + /// For realtime media it is `[peer_index: u8][client frame]` — the + /// peer_index is relay routing metadata (owner pod is sole allocator); + /// the client frame's encrypted content is NIP-44 between client + /// endpoints, so server-side plaintext of the media itself never exists. + pub payload: Vec, +} + +/// One length-delimited frame on a mesh bi-stream. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum MeshStreamFrame { + /// MUST be the first frame on every stream, in both directions. + Hello(StreamHello), + /// Opaque tunnel bytes for a reliable-stream session. + Data { + fenced: FencedHeader, + payload: Vec, + }, + /// Clean close: the sender will send no more `Data` for this session. + /// Distinct from a QUIC reset — receivers treat reset as abnormal. + Goodbye { + fenced: FencedHeader, + reason: GoodbyeReason, + }, + /// Membership gossip on the control stream (one per peer connection). + /// Payload is the gossip lane's postcard-encoded digest/delta exchange — + /// opaque at this layer so gossip can evolve without a wire bump here. + Gossip { payload: Vec }, +} + +/// Stream role, declared in the Hello. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum StreamRole { + /// The per-connection control stream (gossip + liveness). Exactly one + /// per peer connection, opened by the dialer immediately after connect. + Control, + /// A reliable-stream tunnel session. + Session { + fenced: FencedHeader, + profile: Profile, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StreamHello { + pub sender: RuntimeId, + pub role: StreamRole, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum GoodbyeReason { + /// Client closed / session ended normally. + SessionEnded, + /// This runtime is draining (SIGTERM) — re-establish elsewhere. + Draining, + /// The sender observed a newer generation and is fencing itself out. + StaleGeneration, +} + +/// Encode a frame: version byte + postcard. +pub fn encode(frame: &T) -> Result, crate::MeshError> { + let buf = vec![WIRE_VERSION]; + postcard::to_extend(frame, buf).map_err(crate::MeshError::Encode) +} + +/// Decode a frame: check version byte, then postcard. +pub fn decode<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Result { + match bytes.split_first() { + Some((&WIRE_VERSION, rest)) => postcard::from_bytes(rest).map_err(crate::MeshError::Decode), + Some((&v, _)) => Err(crate::MeshError::UnknownWireVersion(v)), + None => Err(crate::MeshError::EmptyFrame), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fenced() -> FencedHeader { + FencedHeader { + session_id: Uuid::from_u128(0xDEAD_BEEF), + generation: 42, + owner_runtime_id: RuntimeId([7u8; 32]), + } + } + + #[test] + fn datagram_roundtrip() { + let d = MeshDatagram { + fenced: fenced(), + seq: 9001, + payload: vec![1, 2, 3], + }; + let bytes = encode(&d).unwrap(); + assert_eq!(bytes[0], WIRE_VERSION); + let back: MeshDatagram = decode(&bytes).unwrap(); + assert_eq!(back, d); + } + + #[test] + fn stream_frame_roundtrip() { + for f in [ + MeshStreamFrame::Hello(StreamHello { + sender: RuntimeId([1u8; 32]), + role: StreamRole::Session { + fenced: fenced(), + profile: Profile::ReliableStream, + }, + }), + MeshStreamFrame::Data { + fenced: fenced(), + payload: b"opaque".to_vec(), + }, + MeshStreamFrame::Goodbye { + fenced: fenced(), + reason: GoodbyeReason::Draining, + }, + MeshStreamFrame::Gossip { + payload: vec![0xAA; 16], + }, + ] { + let back: MeshStreamFrame = decode(&encode(&f).unwrap()).unwrap(); + assert_eq!(back, f); + } + } + + #[test] + fn unknown_version_rejected() { + let d = MeshDatagram { + fenced: fenced(), + seq: 1, + payload: vec![], + }; + let mut bytes = encode(&d).unwrap(); + bytes[0] = 99; + assert!(matches!( + decode::(&bytes), + Err(crate::MeshError::UnknownWireVersion(99)) + )); + } + + /// Opus @ 20ms worst case (~160B) + header must clear the conservative + /// QUIC datagram floor (~1200B path MTU minus QUIC overhead). This pins + /// the header overhead so it can't silently grow past the budget. + #[test] + fn datagram_header_overhead_within_budget() { + let payload = vec![0u8; 160]; + let d = MeshDatagram { + fenced: fenced(), + seq: u64::MAX, + payload: payload.clone(), + }; + let overhead = encode(&d).unwrap().len() - payload.len(); + assert!( + overhead <= 64, + "datagram header overhead {overhead}B exceeds 64B budget" + ); + } +} diff --git a/crates/buzz-relay/CHANGELOG.md b/crates/buzz-relay/CHANGELOG.md index 4f7ab940d6..c0d6ab9ccd 100644 --- a/crates/buzz-relay/CHANGELOG.md +++ b/crates/buzz-relay/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## relay-v0.2.0 + +- feat: relay invite links (mint + claim + landing page + deep link) ([#1668](https://github.com/block/buzz/pull/1668)) ([`2e529aab7`](https://github.com/block/buzz/commit/2e529aab759a18c1bb81e447f3696fe99db53a27)) +- feat(relay): add atomic create-only community provisioning ([#1689](https://github.com/block/buzz/pull/1689)) ([`1de916bdd`](https://github.com/block/buzz/commit/1de916bdd2ad082fb1d33aa1530c483f4a2347ff)) +- feat(media): write per-upload-event records for moderation ([#1551](https://github.com/block/buzz/pull/1551)) ([`83fc30b14`](https://github.com/block/buzz/commit/83fc30b14da3ecad5fc9fdd6d71a9bd3ac45c19e)) +- fix(desktop): paginate complete channel directory ([#1690](https://github.com/block/buzz/pull/1690)) ([`b41cf3ffc`](https://github.com/block/buzz/commit/b41cf3ffc6d232dc1199f7563bd3c4da8571bfe1)) +- [codex] Scope derived runtime state by community ([#1673](https://github.com/block/buzz/pull/1673)) ([`624bd26ce`](https://github.com/block/buzz/commit/624bd26ceae45430f11d263fb73744b648be6314)) +- feat(relay): add operator community provisioning ([#1657](https://github.com/block/buzz/pull/1657)) ([`e0f76b0e9`](https://github.com/block/buzz/commit/e0f76b0e9cbd1c84f1ed064f120bc38ab7006d46)) +- feat: add deeplink nostr identity binding flow ([#1648](https://github.com/block/buzz/pull/1648)) ([`cecd03142`](https://github.com/block/buzz/commit/cecd031428358d7b3aa0326ccca74649bd928231)) +- feat(relay,desktop): canonicalize agent definitions on kind:30175 (Phase 2) ([#1655](https://github.com/block/buzz/pull/1655)) ([`b2c63291f`](https://github.com/block/buzz/commit/b2c63291f1e432951f49c57bd1ec65812870f515)) +- Community moderation Phase 1: reports, bans/timeouts, audit, tombstones, relay-DM notices ([#1616](https://github.com/block/buzz/pull/1616)) ([`863aeb79f`](https://github.com/block/buzz/commit/863aeb79f35a43222bd5de3901ab06220948fb9d)) +- feat(desktop): canonical component — hover to view/copy full keys, owner "you" labels ([#1589](https://github.com/block/buzz/pull/1589)) ([`777babf39`](https://github.com/block/buzz/commit/777babf3938a2ae7ef97fd12f12e6247e7c18cec)) +- feat(nips,relay,acp): NIP-AM durable encrypted agent turn metrics (kind 44200) ([#1441](https://github.com/block/buzz/pull/1441)) ([`71265ca36`](https://github.com/block/buzz/commit/71265ca36105dbf62453a99c998c3f3dd134a304)) +- Live thread-summary push: badge counts update on reply ingest ([#1521](https://github.com/block/buzz/pull/1521)) ([`74a30c3de`](https://github.com/block/buzz/commit/74a30c3de8ff61005fecac53eb007e8a616a9229)) +- fix: let agent owners delete their agent's messages (relay kind:5 + desktop/mobile UX) ([#1519](https://github.com/block/buzz/pull/1519)) ([`642800548`](https://github.com/block/buzz/commit/6428005487f0690019dc27449a6a52cc29cc6479)) +- perf: GIN index for e-tag containment + delta profile fetch (scroll-back ~2.1s/page) ([#1514](https://github.com/block/buzz/pull/1514)) ([`33886e3de`](https://github.com/block/buzz/commit/33886e3dec4130e512d8242207adfe2811a92579)) +- GUI read-model overhaul: server-assembled channel windows (Correct™ pagination + relay-signed bounds) ([#1500](https://github.com/block/buzz/pull/1500)) ([`62bb9fe8c`](https://github.com/block/buzz/commit/62bb9fe8c81eee6573c434ffa3227fa96ad9dd4b)) +- feat(desktop): repository-first projects with git workflows ([#1471](https://github.com/block/buzz/pull/1471)) ([`8e3c0ee95`](https://github.com/block/buzz/commit/8e3c0ee958af8777ba54fd835de03b0e8eada531)) +- feat: per-community workspace icon set by admins, served via NIP-11 ([#1463](https://github.com/block/buzz/pull/1463)) ([`5bfd5ca27`](https://github.com/block/buzz/commit/5bfd5ca2700483498e83224a40a5628a29cf2e9e)) +- perf(relay): batch outbound websocket data frames ([#1464](https://github.com/block/buzz/pull/1464)) ([`01b92faa1`](https://github.com/block/buzz/commit/01b92faa156648835f143e84583b8ec3bd7490ab)) +- Make reaction ingest atomic ([#1458](https://github.com/block/buzz/pull/1458)) ([`835302cc8`](https://github.com/block/buzz/commit/835302cc829c8a63bf254d3e40156fc446e040f6)) +- Serialize fan-out EVENT frames once ([#1459](https://github.com/block/buzz/pull/1459)) ([`3c661fb48`](https://github.com/block/buzz/commit/3c661fb48f81c294b529592d2b2ff874bf96ee96)) +- perf(relay): bounded-concurrency multi-filter query execution (S2) ([#1457](https://github.com/block/buzz/pull/1457)) ([`a9e752e25`](https://github.com/block/buzz/commit/a9e752e2540a94d304a51ddeecbf68464ca9ec69)) +- fix(read-path): reach complete threads, dense-second timelines, and all people in the GUI ([#1418](https://github.com/block/buzz/pull/1418)) ([`7da936fff`](https://github.com/block/buzz/commit/7da936fff82a9a956f338c690e9605888725ea3b)) +- E1+E3: reduce relay ingest/fan-out DB round trips; ack p99 −7–16%, fd p99 −6–28%, p999 tails −29–53% vs PR #1453 tip ([#1454](https://github.com/block/buzz/pull/1454)) ([`a504ad619`](https://github.com/block/buzz/commit/a504ad6197558575c0db7b9f53806d7337e0c64f)) +- perf(relay): defer post-commit dispatch and avoid verify clone ([#1453](https://github.com/block/buzz/pull/1453)) ([`7bd3760c8`](https://github.com/block/buzz/commit/7bd3760c82a6d640af199ed2301525877e629ced)) +- fix(relay): include git hook tools in runtime image ([#1326](https://github.com/block/buzz/pull/1326)) ([`88c089d3b`](https://github.com/block/buzz/commit/88c089d3b652bc952adbe8b32a6fc585121c982f)) +- fix(relay): remove media bearer-token auth ([#1444](https://github.com/block/buzz/pull/1444)) ([`0701f47f4`](https://github.com/block/buzz/commit/0701f47f4a31a904ebcd9f360cbd6aadaff9d784)) +- feat(relay): add OpenTelemetry tracing, keep Prometheus metrics ([#1398](https://github.com/block/buzz/pull/1398)) ([`b1d9d955d`](https://github.com/block/buzz/commit/b1d9d955de83538c231a3034bf190af6df03070d)) +- feat(git): move repo-name registry to Postgres + relax RWM chart gate (HA relay) ([#1432](https://github.com/block/buzz/pull/1432)) ([`e5aa4a213`](https://github.com/block/buzz/commit/e5aa4a21327438c02fb25baea4d0849a498c9059)) +- fix(relay): enable Redis TLS for rediss:// (ElastiCache) ([#1417](https://github.com/block/buzz/pull/1417)) ([`3292b502a`](https://github.com/block/buzz/commit/3292b502aad44a5f849296d7bf28429bac272fb7)) +- feat(relay): allow agent owners to edit/manage agent-owned content ([#1403](https://github.com/block/buzz/pull/1403)) ([`0042c8e10`](https://github.com/block/buzz/commit/0042c8e106d952f408cf4afca052f7053a7c967e)) +- fix(media): support IRSA/credential-chain S3 auth and configurable signing region ([#1406](https://github.com/block/buzz/pull/1406)) ([`06ef533ec`](https://github.com/block/buzz/commit/06ef533ec7fec6cf7366f52d3b9fe2f83011bf24)) +- Harden relay attack surfaces ([#1369](https://github.com/block/buzz/pull/1369)) ([`29368cf17`](https://github.com/block/buzz/commit/29368cf17b7d5924fe571512b2194e3f48b21a16)) +- Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) ([#1321](https://github.com/block/buzz/pull/1321)) ([`14fba21e5`](https://github.com/block/buzz/commit/14fba21e57b8d671ebbea473226be52a5f2ae636)) +- Fix cross-pod membership notification fanout ([#1291](https://github.com/block/buzz/pull/1291)) ([`e1c51d71d`](https://github.com/block/buzz/commit/e1c51d71d48f9ad0a08c3e7e0af35929a0234ea1)) +- Add NIP-34 git pull request CLI support ([#1279](https://github.com/block/buzz/pull/1279)) ([`5fef9b727`](https://github.com/block/buzz/commit/5fef9b727fb8856d45840ccc97d8ec820a48f6e0)) +- chore: remove LLM-slop comments across the codebase ([#1277](https://github.com/block/buzz/pull/1277)) ([`73cc31cc5`](https://github.com/block/buzz/commit/73cc31cc528318debedd38e85d67b79d1feb55e8)) + + ## relay-v0.1.1 - Initial release diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 1134b2c703..36cc71dcb0 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -4,7 +4,7 @@ default-run = "buzz-relay" # Independent version: buzz-relay ships as a pinnable artifact # (ghcr.io/block/buzz), released on its own cadence via `just release-relay`. # It does NOT inherit the workspace version. -version = "0.1.1" +version = "0.2.0" edition.workspace = true rust-version.workspace = true license.workspace = true @@ -23,6 +23,9 @@ buzz-auth = { workspace = true } buzz-pubsub = { workspace = true } buzz-audit = { workspace = true } buzz-search = { workspace = true } +buzz-relay-mesh = { workspace = true } +async-trait = "0.1" +postcard = { workspace = true } axum = { workspace = true } tokio = { workspace = true } # `io` (ReaderStream) is needed to stream git subprocess stdout straight into @@ -54,6 +57,7 @@ redis = { workspace = true } # main(). Mirrors buzz-acp's rustls setup for wss://. rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } sqlx = { workspace = true } +reqwest = { workspace = true } base64 = "0.22" buzz-sdk = { workspace = true } buzz-workflow = { workspace = true, features = ["reqwest"] } @@ -72,13 +76,14 @@ url = { workspace = true } moka = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } +metrics-util = { workspace = true } [features] dev = ["buzz-auth/dev"] [dev-dependencies] -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } buzz-core = { workspace = true, features = ["test-utils"] } buzz-auth = { workspace = true, features = ["dev"] } reqwest = { workspace = true } diff --git a/crates/buzz-relay/examples/mesh_admission_smoke.rs b/crates/buzz-relay/examples/mesh_admission_smoke.rs new file mode 100644 index 0000000000..a541f35bbc --- /dev/null +++ b/crates/buzz-relay/examples/mesh_admission_smoke.rs @@ -0,0 +1,452 @@ +//! Owner-allowlist admission smoke test — three nodes, one per process. +//! +//! Proves the security claim of Buzz's membership-derived mesh admission: +//! possession of a dial pointer (invite token / EndpointAddr) admits nobody; +//! only owners on the serve node's allowlist join the mesh. +//! +//! 1. SERVE process: hosts a GGUF with `trust_policy(Allowlist)` + +//! `owner_required(true)`, trusting exactly one other owner id — the +//! same builder calls Buzz desktop makes with a resolved member roster. +//! 2. TRUSTED process: client presenting the allowlisted owner key, joins +//! via the invite token, must see the routed model; the orchestrator +//! then drives a real inference through it. +//! 3. NON-MEMBER process: client presenting a different owner key with the +//! same connection information. It must not be admitted or route inference. +//! +//! One process per node is load-bearing: mesh-llm keeps process-global state +//! (ownership attestation at ~/.mesh-llm/node-ownership.json, tracing, the +//! output sink), so multiple owner-keyed embedded nodes in one process +//! corrupt each other — exactly how Buzz runs it in production anyway (one +//! desktop = one node). +//! +//! Hardware-gated, not CI — loads a real model. Run with: +//! cargo run -p buzz-relay --example mesh_admission_smoke +use std::io::BufRead; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use mesh_llm_host_runtime::crypto::{save_keystore, OwnerKeypair}; +use mesh_llm_sdk::{client, serve, MeshDiscoveryMode, TrustPolicy}; + +const DEFAULT_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M"; + +const SERVE_API_PORT: u16 = 19347; +const SERVE_CONSOLE_PORT: u16 = 13141; +const TRUSTED_API_PORT: u16 = 19348; +const TRUSTED_CONSOLE_PORT: u16 = 13142; +const STRANGER_API_PORT: u16 = 19349; +const STRANGER_CONSOLE_PORT: u16 = 13143; + +/// The trusted client sees the model within seconds on one box; this bounds +/// the stranger's chance to (fail to) see it. +const TRUSTED_WINDOW_SECS: u64 = 120; +const STRANGER_WINDOW_SECS: u64 = 45; + +fn main() -> anyhow::Result<()> { + match std::env::var("MESH_ROLE").ok().as_deref() { + Some("serve") => tokio::runtime::Runtime::new()?.block_on(role_serve()), + Some("client") => tokio::runtime::Runtime::new()?.block_on(role_client()), + _ => orchestrate(), + } +} + +fn env(name: &str) -> anyhow::Result { + std::env::var(name).map_err(|_| anyhow::anyhow!("{name} is required for this role")) +} + +async fn init_native_runtime() -> anyhow::Result<()> { + let cache = mesh_llm_sdk::native_runtime::native_runtime_cache(None)?; + let current = mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION; + if !cache + .installed()? + .iter() + .any(|runtime| runtime.mesh_version == current) + { + anyhow::bail!("MeshLLM native runtime for MeshLLM {current} is not installed; run `just mesh-e2e-hardware` once to prepare it"); + } + mesh_llm_host_runtime::initialize_host_runtime() + .await + .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error}")) +} + +/// SERVE role: allowlist serve node. Prints `INVITE:` then +/// `READY:` on stdout, then parks until the orchestrator kills it. +async fn role_serve() -> anyhow::Result<()> { + init_native_runtime().await?; + let model = env("MESH_SMOKE_MODEL")?; + let owner_key = env("MESH_OWNER_KEY")?; + let trust_owners: Vec = env("MESH_TRUST_OWNERS")? + .split(',') + .map(str::to_string) + .collect(); + + let cfg = serve::EmbeddedServeConfig::builder() + .model(&model) + .api_port(SERVE_API_PORT) + .console_port(SERVE_CONSOLE_PORT) + .publish(true) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Mdns) + .console_ui(true) + .startup_timeout(Duration::from_secs(600)) + .owner_key(owner_key) + .owner_required(true) + .trust_policy(TrustPolicy::Allowlist) + .trust_owners(trust_owners) + // Require signed bootstrap tokens so owner admission is enforced from + // the first connection attempt. + .signed_join_tokens(true) + .build(); + let node = serve::start(cfg).await?; + let invite = node + .invite_token() + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("serve node produced no invite token"))?; + println!("INVITE:{invite}"); + + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + match wait_for_model(&http, &base, Duration::from_secs(600)).await? { + Some(model) => println!("READY:{model}"), + None => anyhow::bail!("serve node never loaded the model"), + } + // Park; the orchestrator kills this process when the run is over. + loop { + tokio::time::sleep(Duration::from_secs(3600)).await; + } +} + +/// CLIENT role (trusted or stranger — the key decides). Joins via the invite +/// token, polls its own /models for the window, prints `SEEN:` or +/// `NONE`, stops the node, exits 0. +async fn role_client() -> anyhow::Result<()> { + init_native_runtime().await?; + let owner_key = env("MESH_OWNER_KEY")?; + let join_token = env("MESH_JOIN_TOKEN")?; + let api_port: u16 = env("MESH_API_PORT")?.parse()?; + let console_port: u16 = env("MESH_CONSOLE_PORT")?.parse()?; + let window_secs: u64 = env("MESH_WINDOW_SECS")?.parse()?; + + let cfg = client::EmbeddedClientConfig::builder() + .api_port(api_port) + .console_port(console_port) + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Mdns) + .join_token(&join_token) + .startup_timeout(Duration::from_secs(180)) + .console_ui(true) + // Present the owner attestation; owner_required makes a key-load + // failure abort loudly instead of silently starting unattested + // (which the allowlist serve would then reject as NoAttestation). + .owner_key(owner_key) + .owner_required(true) + .build(); + let node = client::start(cfg).await?; + let http = reqwest::Client::new(); + let base = node.api_base_url().to_string(); + let seen = wait_for_model(&http, &base, Duration::from_secs(window_secs)).await?; + match &seen { + Some(model) => { + println!("SEEN:{model}"); + // Visibility is gossip; admission is routing. The decisive probe + // is whether an inference actually routes through the mesh. + match try_completion(&http, &base, model).await { + Ok(content) => println!("INFER_OK:{content}"), + Err(error) => println!("INFER_FAIL:{error}"), + } + } + None => println!("NONE"), + } + let _ = node.stop().await; + // Skip C++ static destructors (ggml Metal aborts in global teardown). + std::process::exit(0); +} + +/// Orchestrator: keystores, three child processes, assertions, inference. +fn orchestrate() -> anyhow::Result<()> { + let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + eprintln!("[admission] model: {model}"); + + let scratch = std::env::temp_dir().join(format!("buzz-mesh-admission-{}", std::process::id())); + std::fs::create_dir_all(&scratch)?; + let make_owner = |name: &str| -> anyhow::Result<(String, String)> { + let keypair = OwnerKeypair::generate(); + let path = scratch.join(format!("{name}.keystore.json")); + save_keystore(&path, &keypair, None, true) + .map_err(|error| anyhow::anyhow!("saving {name} keystore: {error}"))?; + Ok((path.display().to_string(), keypair.owner_id())) + }; + let (serve_key, serve_owner) = make_owner("serve")?; + let (trusted_key, trusted_owner) = make_owner("trusted")?; + let (stranger_key, stranger_owner) = make_owner("stranger")?; + eprintln!("[admission] owners — serve: {serve_owner}, trusted: {trusted_owner}, stranger: {stranger_owner}"); + + // Each role gets an isolated HOME: mesh-llm keeps its node endpoint key + // and node-ownership.json under ~/.mesh-llm, so subprocesses sharing the + // real HOME would share a node identity and clobber each other's + // attestations. The native runtime cache must still point at the real + // one (it resolves via HOME otherwise). + let real_cache = std::env::var_os("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR") + .map(std::path::PathBuf::from) + .unwrap_or(dirs_cache_dir()?.join("mesh-llm/native-runtimes")); + let role_home = |name: &str| -> anyhow::Result { + let home = scratch.join(format!("{name}-home")); + std::fs::create_dir_all(&home)?; + Ok(home.display().to_string()) + }; + let serve_home = role_home("serve")?; + let trusted_home = role_home("trusted")?; + let stranger_home = role_home("stranger")?; + + let exe = std::env::current_exe()?; + + // 1. Serve child with allowlist {serve, trusted}. + eprintln!("[admission] starting allowlist serve node (subprocess)..."); + let mut serve_child = Command::new(&exe) + .env("MESH_ROLE", "serve") + .env("MESH_SMOKE_MODEL", &model) + .env("MESH_OWNER_KEY", &serve_key) + .env("HOME", &serve_home) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &real_cache) + .env( + "MESH_TRUST_OWNERS", + format!("{serve_owner},{trusted_owner}"), + ) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let serve_guard = KillOnDrop(&mut serve_child); + + let serve_stdout = serve_guard + .0 + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no serve stdout"))?; + let mut serve_lines = std::io::BufReader::new(serve_stdout).lines(); + let invite = expect_line(&mut serve_lines, "INVITE:", Duration::from_secs(180))?; + eprintln!("[admission] serve invite token acquired"); + let served_model = expect_line(&mut serve_lines, "READY:", Duration::from_secs(600))?; + eprintln!("[admission] serve model ready: {served_model}"); + + // 2. Trusted client child: allowlisted owner + the invite token. + eprintln!("[admission] starting TRUSTED client (allowlisted owner)..."); + let trusted_out = run_client( + &exe, + ClientRun { + owner_key: &trusted_key, + home: &trusted_home, + cache_dir: &real_cache, + invite: &invite, + api_port: TRUSTED_API_PORT, + console_port: TRUSTED_CONSOLE_PORT, + window_secs: TRUSTED_WINDOW_SECS, + }, + )?; + let routed = match trusted_out.seen.as_deref() { + Some(model) => model.to_string(), + None => anyhow::bail!("ADMISSION FAIL: trusted (allowlisted) client never saw the model"), + }; + eprintln!("[admission] PASS 1/3: trusted client admitted, sees routed model: {routed}"); + let content = match trusted_out.infer_ok.as_deref() { + Some(content) => content.to_string(), + None => anyhow::bail!( + "ADMISSION FAIL: trusted client saw the model but inference did not route: {:?}", + trusted_out.infer_fail + ), + }; + eprintln!("[admission] PASS 2/3: trusted client inference routed over mesh: {content:?}"); + let _ = served_model; // serve-side id retained for logs only + + // 4. Non-member child: same connection information, non-allowlisted owner key. + eprintln!("[admission] starting NON-MEMBER client (owner is not allowlisted)..."); + let stranger_out = run_client( + &exe, + ClientRun { + owner_key: &stranger_key, + home: &stranger_home, + cache_dir: &real_cache, + invite: &invite, + api_port: STRANGER_API_PORT, + console_port: STRANGER_CONSOLE_PORT, + window_secs: STRANGER_WINDOW_SECS, + }, + )?; + match ( + stranger_out.seen.as_deref(), + stranger_out.infer_ok.as_deref(), + stranger_out.infer_fail.as_deref(), + ) { + (None, None, _) => eprintln!( + "[admission] PASS 3/3: non-member saw no model ({STRANGER_WINDOW_SECS}s window)" + ), + (Some(model), None, Some(error)) => eprintln!( + "[admission] PASS 3/3: non-member saw gossip for {model} but inference was rejected: {error}" + ), + (Some(model), Some(content), _) => anyhow::bail!( + "ADMISSION FAIL: non-member reused the invite token and inferred through {model}: {content:?}" + ), + (Some(model), None, None) => anyhow::bail!( + "ADMISSION INCONCLUSIVE: non-member saw {model} but produced no inference verdict" + ), + (None, Some(content), _) => anyhow::bail!( + "ADMISSION FAIL: non-member inferred without model visibility: {content:?}" + ), + } + eprintln!("[admission] PASS: owner allowlist gates mesh membership and inference"); + + drop(serve_guard); // kills the serve child + let _ = serve_child.wait(); + let _ = std::fs::remove_dir_all(&scratch); + Ok(()) +} + +/// One chat completion against a node's OpenAI endpoint; Ok(content) only if +/// it really routed and produced non-empty output. +async fn try_completion( + http: &reqwest::Client, + api_base: &str, + model: &str, +) -> anyhow::Result { + let resp = http + .post(format!("{api_base}/chat/completions")) + .timeout(Duration::from_secs(60)) + .json(&serde_json::json!({ + "model": model, + "messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}], + "max_tokens": 16, + "temperature": 0.0 + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + if !status.is_success() { + anyhow::bail!("{status}: {body}"); + } + let content = serde_json::from_str::(&body)?["choices"][0]["message"] + ["content"] + .as_str() + .unwrap_or("") + .to_string(); + if content.trim().is_empty() { + anyhow::bail!("empty content"); + } + Ok(content) +} + +/// The real user's OS cache dir (macOS: ~/Library/Caches), resolved before +/// we override HOME for the child processes. +fn dirs_cache_dir() -> anyhow::Result { + let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME is not set"))?; + #[cfg(target_os = "macos")] + return Ok(std::path::PathBuf::from(home).join("Library/Caches")); + #[cfg(not(target_os = "macos"))] + return Ok(std::path::PathBuf::from(home).join(".cache")); +} + +struct ClientRun<'a> { + owner_key: &'a str, + home: &'a str, + cache_dir: &'a std::path::Path, + invite: &'a str, + api_port: u16, + console_port: u16, + window_secs: u64, +} + +/// Spawn a client-role child and return its verdict line (`SEEN:…` / `NONE`). +fn run_client(exe: &std::path::Path, run: ClientRun<'_>) -> anyhow::Result { + let output = Command::new(exe) + .env("MESH_ROLE", "client") + .env("MESH_OWNER_KEY", run.owner_key) + .env("HOME", run.home) + .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", run.cache_dir) + .env("MESH_JOIN_TOKEN", run.invite) + .env("MESH_API_PORT", run.api_port.to_string()) + .env("MESH_CONSOLE_PORT", run.console_port.to_string()) + .env("MESH_WINDOW_SECS", run.window_secs.to_string()) + .stderr(Stdio::inherit()) + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let mut verdict = ClientVerdict::default(); + let mut saw_any = false; + for line in stdout.lines() { + if let Some(model) = line.strip_prefix("SEEN:") { + verdict.seen = Some(model.to_string()); + saw_any = true; + } else if line == "NONE" { + saw_any = true; + } else if let Some(content) = line.strip_prefix("INFER_OK:") { + verdict.infer_ok = Some(content.to_string()); + } else if let Some(error) = line.strip_prefix("INFER_FAIL:") { + verdict.infer_fail = Some(error.to_string()); + } + } + if !saw_any { + anyhow::bail!("client child produced no verdict; stdout: {stdout}"); + } + Ok(verdict) +} + +/// What a client-role child reported on stdout. +#[derive(Debug, Default)] +struct ClientVerdict { + /// Model id if the routed model became visible in the window. + seen: Option, + /// Completion content if an inference actually routed over the mesh. + infer_ok: Option, + /// Inference error if visibility existed but routing failed. + infer_fail: Option, +} + +/// Read serve-child stdout until a line with the given prefix appears. +fn expect_line( + lines: &mut std::io::Lines>, + prefix: &str, + timeout: Duration, +) -> anyhow::Result { + // BufReader::lines blocks; enforce the timeout coarsely via a deadline + // check between lines (the child prints continuously enough in practice). + let deadline = std::time::Instant::now() + timeout; + for line in lines.by_ref() { + let line = line?; + if let Some(rest) = line.strip_prefix(prefix) { + return Ok(rest.to_string()); + } + if std::time::Instant::now() > deadline { + break; + } + } + anyhow::bail!("serve child ended or timed out before printing {prefix}") +} + +/// Kill the serve child on drop so a failed assertion never leaks a process. +struct KillOnDrop<'a>(&'a mut Child); +impl Drop for KillOnDrop<'_> { + fn drop(&mut self) { + let _ = self.0.kill(); + } +} + +/// Poll `/models` until a model id appears or the window closes. +async fn wait_for_model( + http: &reqwest::Client, + api_base: &str, + window: Duration, +) -> anyhow::Result> { + let url = format!("{api_base}/models"); + let deadline = std::time::Instant::now() + window; + while std::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_secs(3)).await; + if let Ok(resp) = http.get(&url).send().await { + let body = resp.text().await.unwrap_or_default(); + if let Ok(json) = serde_json::from_str::(&body) { + if let Some(id) = json["data"].get(0).and_then(|m| m["id"].as_str()) { + return Ok(Some(id.to_string())); + } + } + } + } + Ok(None) +} diff --git a/crates/buzz-relay/examples/mesh_agent_e2e.rs b/crates/buzz-relay/examples/mesh_agent_e2e.rs new file mode 100644 index 0000000000..b6f723f35a --- /dev/null +++ b/crates/buzz-relay/examples/mesh_agent_e2e.rs @@ -0,0 +1,421 @@ +//! End-to-end mesh + agent permutation harness — fully headless, no desktop +//! app, no keychain. Proves the whole chain the UI exercises: +//! +//! share compute (serve node) → agent env preset → ACP agent → inference +//! +//! Permutations: +//! P1 explicit-model chat — agent pinned to the served model id replies. +//! P2 auto-model chat — agent sends `model: "auto"`; mesh router picks. +//! P3 context-fit regression — an oversized output budget (150k tokens) +//! must FAIL with the router's context error (proves the router's fit +//! gate — the failure mode the 1024 preset cap protects against). +//! P4 agentic tool use — agent + buzz-dev-mcp writes a file on disk. +//! +//! The serve node is the same `mesh_llm_sdk::serve` path Share-compute uses +//! (publish off, mdns, loopback). The agent legs spawn the real +//! `buzz-agent` binary with the exact env vars the relay-mesh preset ships. +//! +//! Hardware-gated, not CI. Run: +//! cargo build --release -p buzz-agent -p buzz-dev-mcp +//! cargo run -p buzz-relay --example mesh_agent_e2e +//! Env: MESH_E2E_MODEL overrides the served model ref. +use std::process::Stdio; +use std::time::Duration; + +use mesh_llm_sdk::{serve, MeshDiscoveryMode}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; + +// Qwen3-8B: cached GGUF *and* complete layer package on this class of +// machine, so the serve node starts in seconds. Qwen3-30B-A3B works too but +// mesh-llm serves it from layer packages and will download them on first +// serve (~7GB) — fine in the app (progress UI), too slow for a smoke. +const DEFAULT_MODEL: &str = "unsloth/Qwen3-8B-GGUF:Q4_K_M"; +const API_PORT: u16 = 19437; +const CONSOLE_PORT: u16 = 13231; + +fn main() -> anyhow::Result<()> { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + // Same fix the desktop ships: mesh-llm futures need >2MiB stacks. + .thread_stack_size(8 * 1024 * 1024) + .build()? + .block_on(run()) +} + +async fn run() -> anyhow::Result<()> { + let model = std::env::var("MESH_E2E_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + mesh_llm_host_runtime::initialize_host_runtime() + .await + .map_err(|e| anyhow::anyhow!("host runtime init: {e}"))?; + + eprintln!("[e2e] starting serve node with {model} (loading may take a minute)..."); + let cfg = serve::EmbeddedServeConfig::builder() + .model(&model) + .api_port(API_PORT) + .console_port(CONSOLE_PORT) + .publish(false) + .auto_join(false) + .discovery_mode(MeshDiscoveryMode::Mdns) + .console_ui(true) // readiness poll needs the console bound + .startup_timeout(Duration::from_secs(300)) + .build(); + let node = serve::start(cfg) + .await + .map_err(|e| anyhow::anyhow!("serve start: {e}"))?; + let base = node.api_base_url().to_string(); + + // Wait for the model to be loaded + resolvable, capture its served id. + let http = reqwest::Client::new(); + let mut served_id = String::new(); + for _ in 0..120 { + if let Ok(resp) = http.get(format!("{base}/models")).send().await { + if let Ok(json) = resp.json::().await { + if let Some(id) = json["data"].get(0).and_then(|m| m["id"].as_str()) { + served_id = id.to_string(); + break; + } + } + } + tokio::time::sleep(Duration::from_secs(5)).await; + } + anyhow::ensure!(!served_id.is_empty(), "model never appeared in /models"); + eprintln!("[e2e] node up, served id = {served_id}"); + + let mut pass = 0usize; + let mut fail = 0usize; + let mut record = |name: &str, ok: bool, detail: String| { + if ok { + pass += 1; + eprintln!("[e2e] PASS {name}: {detail}"); + } else { + fail += 1; + eprintln!("[e2e] FAIL {name}: {detail}"); + } + }; + + // P1: explicit model id. + let r = agent_chat( + &base, + &served_id, + None, + "Reply with exactly one word: PONG", + &[], + ) + .await; + match r { + Ok(text) => record( + "P1 explicit-model chat", + text.to_uppercase().contains("PONG"), + text, + ), + Err(e) => record("P1 explicit-model chat", false, e.to_string()), + } + + // P2: auto — router picks the model. + let r = agent_chat( + &base, + "auto", + None, + "Reply with exactly one word: PONG", + &[], + ) + .await; + match r { + Ok(text) => record( + "P2 auto-model chat", + text.to_uppercase().contains("PONG"), + text, + ), + Err(e) => record("P2 auto-model chat", false, e.to_string()), + } + + // P3: regression — an output budget no served model's context can hold + // must be rejected by the router with the context-fit error (the failure + // mode that broke relay-mesh agents when buzz-agent's default 32768 + // budget met a 32k-context model). 150k output: passes buzz-agent's own + // config validation (must stay under its 200k max_context_tokens) but + // with the router's +25% margin overflows even 128k-context models like + // GLM-4.7-Flash. + let r = agent_chat( + &base, + &served_id, + Some("150000"), + "Reply with exactly one word: PONG", + &[], + ) + .await; + match r { + Ok(text) => record( + "P3 oversized-budget must fail", + false, + format!("unexpectedly succeeded: {text}"), + ), + Err(e) => { + let msg = e.to_string(); + let is_context_503 = msg.contains("503") + || msg.contains("service_unavailable") + || msg.contains("context-compatible"); + record("P3 oversized-budget must fail", is_context_503, msg); + } + } + + // P4: agentic tool use via buzz-dev-mcp — write a real file inside the + // isolated ACP working directory. The MCP sandbox intentionally rejects + // nonexistent absolute paths outside that root. + let marker_name = format!("mesh-e2e-{}.txt", std::process::id()); + let prompt = format!( + "Use your developer tools to create {marker_name} in the current working directory containing exactly the text BUZZ_OK (no quotes, no newline commentary). Then confirm." + ); + let mcp = vec![("dev".to_string(), repo_bin("buzz-dev-mcp")?)]; + let (r, marker) = + agent_chat_with_marker(&base, &served_id, None, &prompt, &mcp, &marker_name).await; + let file_ok = std::fs::read_to_string(&marker) + .map(|c| c.contains("BUZZ_OK")) + .unwrap_or(false); + match r { + Ok(text) => record( + "P4 agentic tool use", + file_ok, + if file_ok { + format!("file written; agent said: {text}") + } else { + format!("no file at {}; agent said: {text}", marker.display()) + }, + ), + Err(e) => record("P4 agentic tool use", file_ok, format!("agent error: {e}")), + } + let _ = std::fs::remove_file(&marker); + + eprintln!("[e2e] {pass} passed, {fail} failed"); + if fail > 0 { + eprintln!("[e2e] FAIL: {fail} permutation(s) failed"); + exit_without_native_destructors(1); + } + eprintln!("[e2e] PASS: share-compute → agent → inference proven end to end"); + exit_without_native_destructors(0); +} + +/// Replace the process image so libc does not run llama.cpp's crashing Metal +/// `atexit` handlers (mesh-console issue #8). `std::process::exit` is not enough: +/// it skips Rust drops but still runs native C/C++ finalizers. +fn exit_without_native_destructors(code: i32) -> ! { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let program = if code == 0 { "true" } else { "false" }; + let error = std::process::Command::new(program).exec(); + eprintln!("[e2e] failed to exec {program}: {error}"); + } + std::process::exit(code) +} + +fn repo_bin(name: &str) -> anyhow::Result { + let path = std::env::current_dir()?.join("target/release").join(name); + anyhow::ensure!( + path.exists(), + "{} missing — cargo build --release -p {name}", + path.display() + ); + Ok(path.to_string_lossy().into_owned()) +} + +/// Spawn the real buzz-agent with relay-mesh preset env and drive one ACP +/// session/prompt over stdio. Returns the concatenated agent message text, +/// or Err carrying the agent's error message. +async fn agent_chat( + base: &str, + model: &str, + max_output_tokens: Option<&str>, + prompt: &str, + mcp_servers: &[(String, String)], +) -> anyhow::Result { + let (result, _) = + agent_chat_in_isolated_home(base, model, max_output_tokens, prompt, mcp_servers).await; + result +} + +async fn agent_chat_with_marker( + base: &str, + model: &str, + max_output_tokens: Option<&str>, + prompt: &str, + mcp_servers: &[(String, String)], + marker_name: &str, +) -> (anyhow::Result, std::path::PathBuf) { + let (result, home) = + agent_chat_in_isolated_home(base, model, max_output_tokens, prompt, mcp_servers).await; + (result, home.join(marker_name)) +} + +async fn agent_chat_in_isolated_home( + base: &str, + model: &str, + max_output_tokens: Option<&str>, + prompt: &str, + mcp_servers: &[(String, String)], +) -> (anyhow::Result, std::path::PathBuf) { + let agent = match repo_bin("buzz-agent") { + Ok(agent) => agent, + Err(error) => return (Err(error), std::path::PathBuf::new()), + }; + // Isolated HOME: no skills, no AGENTS.md chain, no keychain, tiny prompt. + let home = std::env::temp_dir().join(format!("mesh-e2e-home-{}", std::process::id())); + if let Err(error) = std::fs::create_dir_all(&home) { + return (Err(error.into()), home); + } + + let mut command = Command::new(&agent); + command + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", &home) + // Exactly the environment apply_relay_mesh_env() supplies. + .env("BUZZ_AGENT_PROVIDER", "openai") + .env("BUZZ_AGENT_MODEL", model) + .env("OPENAI_COMPAT_BASE_URL", base) + .env("OPENAI_COMPAT_MODEL", model) + .env("OPENAI_COMPAT_API_KEY", "buzz-mesh-local") + .env("OPENAI_COMPAT_API", "chat") + .env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "4096") + .env("BUZZ_AGENT_THINKING_EFFORT", "none") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + // P3 deliberately overrides the production default to exercise the + // router's context-fit rejection. Normal and tool turns leave it unset, + // matching the desktop provider path. + if let Some(value) = max_output_tokens { + command.env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", value); + } + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => return (Err(error.into()), home), + }; + + let result = drive_acp(&mut child, prompt, mcp_servers, &home).await; + let _ = child.kill().await; + (result, home) +} + +async fn drive_acp( + child: &mut Child, + prompt: &str, + mcp_servers: &[(String, String)], + cwd: &std::path::Path, +) -> anyhow::Result { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("no stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no stdout"))?; + let mut lines = BufReader::new(stdout).lines(); + + let mcp_json: Vec = mcp_servers + .iter() + .map(|(name, command)| { + serde_json::json!({ "name": name, "command": command, "args": [], "env": [] }) + }) + .collect(); + + let send = |v: serde_json::Value| format!("{v}\n"); + stdin + .write_all( + send(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { "protocolVersion": 1, "clientCapabilities": {} } + })) + .as_bytes(), + ) + .await?; + stdin + .write_all( + send(serde_json::json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": { + "cwd": cwd.to_string_lossy(), + "mcpServers": mcp_json, + "systemPrompt": "You are a terse test agent. Follow instructions exactly." + } + })) + .as_bytes(), + ) + .await?; + + let mut session_id: Option = None; + let mut agent_text = String::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(600); + + loop { + let line = tokio::time::timeout_at(deadline, lines.next_line()) + .await + .map_err(|_| anyhow::anyhow!("agent timed out; text so far: {agent_text}"))?? + .ok_or_else(|| anyhow::anyhow!("agent closed stdout; text so far: {agent_text}"))?; + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + // Collect any streamed agent text from session/update notifications. + if msg.get("method").and_then(|m| m.as_str()) == Some("session/update") { + collect_text(&msg["params"]["update"], &mut agent_text); + continue; + } + match msg.get("id").and_then(|i| i.as_i64()) { + Some(2) => { + if let Some(err) = msg.get("error") { + anyhow::bail!("session/new failed: {err}"); + } + let sid = msg["result"]["sessionId"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("session/new: no sessionId: {msg}"))? + .to_string(); + stdin + .write_all( + send(serde_json::json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/prompt", + "params": { + "sessionId": sid, + "prompt": [ { "type": "text", "text": prompt } ] + } + })) + .as_bytes(), + ) + .await?; + session_id = Some(sid); + } + Some(3) => { + anyhow::ensure!(session_id.is_some(), "prompt response before session"); + if let Some(err) = msg.get("error") { + anyhow::bail!("session/prompt failed: {err}"); + } + return Ok(agent_text.trim().to_string()); + } + _ => {} + } + } +} + +/// Recursively harvest "text" string fields out of a session/update payload. +fn collect_text(value: &serde_json::Value, out: &mut String) { + match value { + serde_json::Value::Object(map) => { + for (k, v) in map { + if k == "text" { + if let Some(s) = v.as_str() { + out.push_str(s); + } + } else { + collect_text(v, out); + } + } + } + serde_json::Value::Array(items) => { + for item in items { + collect_text(item, out); + } + } + _ => {} + } +} diff --git a/crates/buzz-relay/examples/mesh_serve_client_smoke.rs b/crates/buzz-relay/examples/mesh_serve_client_smoke.rs index 26baa3f8f6..903d1a73c0 100644 --- a/crates/buzz-relay/examples/mesh_serve_client_smoke.rs +++ b/crates/buzz-relay/examples/mesh_serve_client_smoke.rs @@ -1,13 +1,13 @@ //! Local mesh serve→client→inference smoke test. //! -//! Proves the full path Buzz's "Share compute" + "Run on relay mesh" pair -//! relies on, on a single box and without a relay or Nostr discovery: +//! Proves the full Buzz shared compute serve→consume path on a single box, +//! without a relay or Nostr discovery: //! //! 1. Start a SERVE node hosting a GGUF (the `serve::start` path desktop //! uses in Share-compute mode), and read its mesh invite token. //! 2. Start a CLIENT node joined to that serve node via the invite token -//! (the `client::start` path an agent's "Run on relay mesh" provider -//! uses), binding its own local OpenAI-compatible endpoint. +//! (the `client::start` path a Buzz shared compute agent uses), binding +//! its own local OpenAI-compatible endpoint. //! 3. Drive one chat completion against the CLIENT endpoint and assert it //! routed through the mesh to the serve node and produced real output. //! @@ -51,6 +51,7 @@ async fn main() -> anyhow::Result<()> { anyhow::bail!("MeshLLM native runtime for MeshLLM {current} is not installed; run `just mesh-e2e-hardware` to prepare it"); } mesh_llm_host_runtime::initialize_host_runtime() + .await .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error}"))?; eprintln!("[smoke] MeshLLM host runtime initialized"); @@ -137,7 +138,14 @@ async fn main() -> anyhow::Result<()> { } println!("[smoke] OK — routed completion finish_reason={finish:?} content={content:?}"); eprintln!("[smoke] PASS: serve→client→inference proven over mesh"); - Ok(()) + // Exit immediately, skipping remaining Rust drops (tokio runtime, iroh + // endpoints). The embedded ggml Metal runtime can GGML_ASSERT inside C++ + // static destructors at process teardown when a node wasn't cleanly shut + // down (observed here after a startup failure; mesh-console issue #8 is + // the same crash — its fix is libc::_exit, which this repo's no-unsafe + // rule rules out). Both nodes are stopped above, which is what keeps the + // finalizers quiet on the success path. + std::process::exit(0); } /// Poll a node's `/models` until it reports a model, returning the served id diff --git a/crates/buzz-relay/examples/mesh_stack_smoke.rs b/crates/buzz-relay/examples/mesh_stack_smoke.rs new file mode 100644 index 0000000000..e25a643787 --- /dev/null +++ b/crates/buzz-relay/examples/mesh_stack_smoke.rs @@ -0,0 +1,121 @@ +//! Tokio worker stack-size smoke — reproduces and verifies the fix for the +//! mesh-llm model-download stack overflow (SIGABRT via stack guard). +//! +//! Crash report (2026-07-08, buzz-desktop 0.3.46): enabling Share compute +//! aborted the app on a `tokio-rt-worker` thread inside +//! `mesh_llm_host_runtime::models::resolve::download_model_ref_with_progress_details` +//! — Rust's stack-overflow signal handler fired on tokio's default 2 MiB +//! worker stack. Upstream mesh-llm runs its own binary on 8 MiB worker +//! stacks for exactly this reason (`DEFAULT_WORKER_STACK_SIZE` in mesh-llm +//! `main.rs`), as does mesh-console. +//! +//! This harness polls the same future as a spawned task (matching how Tauri +//! polls command futures on worker threads) in two subprocess legs: +//! +//! 1. 2 MiB worker stacks (tokio default) — expected to DIE from the +//! stack guard (signal, no exit code). Proves we reproduced the crash. +//! 2. 8 MiB worker stacks (the fix installed in desktop `lib.rs` via +//! `tauri::async_runtime::set`) — expected to complete the download. +//! +//! Each leg gets a fresh HF_HOME so the download really runs (a cache hit +//! never reaches the deep code path). Network required; downloads a ~100 MB +//! GGUF twice at most (leg 1 usually dies early). Not CI — run manually: +//! +//! cargo run -p buzz-relay --example mesh_stack_smoke +use std::process::{Command, Stdio}; + +/// Small real model, same one the admission smoke uses. +const MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M"; + +const TOKIO_DEFAULT_STACK: usize = 2 * 1024 * 1024; +/// Must match `buzz_lib::mesh_llm::MESH_WORKER_STACK_SIZE` (desktop crate is +/// not a dependency of buzz-relay, so the value is duplicated here). +const FIXED_STACK: usize = 8 * 1024 * 1024; + +fn main() -> anyhow::Result<()> { + match std::env::var("MESH_STACK_ROLE").ok().as_deref() { + Some("download") => role_download(), + _ => orchestrate(), + } +} + +/// Subprocess: poll the download future as a spawned task on a worker +/// thread with the requested stack size — the exact shape of a Tauri +/// command future. +fn role_download() -> anyhow::Result<()> { + let stack: usize = std::env::var("MESH_STACK_SIZE")?.parse()?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(stack) + .build()?; + runtime.block_on(async { + tokio::spawn(async { + mesh_llm_host_runtime::models::download_model_ref_with_progress_details(MODEL, true) + .await + .map(|_| ()) + .map_err(|error| anyhow::anyhow!("download failed: {error}")) + }) + .await? + })?; + println!("DOWNLOAD_OK"); + // Skip destructors (ggml teardown abort, mesh-console issue #8). + std::process::exit(0); +} + +fn run_leg(stack: usize, hf_home: &std::path::Path) -> anyhow::Result<(bool, Option)> { + let exe = std::env::current_exe()?; + let status = Command::new(exe) + .env("MESH_STACK_ROLE", "download") + .env("MESH_STACK_SIZE", stack.to_string()) + .env("HF_HOME", hf_home) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status()?; + Ok((status.success(), status.code())) +} + +fn orchestrate() -> anyhow::Result<()> { + let scratch = std::env::temp_dir().join(format!("mesh-stack-smoke-{}", std::process::id())); + + println!( + "=== leg 1: {} MiB worker stacks (tokio default) — expecting stack-guard death ===", + TOKIO_DEFAULT_STACK / (1024 * 1024) + ); + let hf1 = scratch.join("hf-2mb"); + std::fs::create_dir_all(&hf1)?; + let (ok_2mb, code_2mb) = run_leg(TOKIO_DEFAULT_STACK, &hf1)?; + + println!( + "=== leg 2: {} MiB worker stacks (the fix) — expecting success ===", + FIXED_STACK / (1024 * 1024) + ); + let hf2 = scratch.join("hf-8mb"); + std::fs::create_dir_all(&hf2)?; + let (ok_8mb, code_8mb) = run_leg(FIXED_STACK, &hf2)?; + + let _ = std::fs::remove_dir_all(&scratch); + + println!(); + println!( + "leg 1 (2 MiB): success={ok_2mb} exit_code={code_2mb:?} (None = killed by signal, i.e. stack guard)" + ); + println!("leg 2 (8 MiB): success={ok_8mb} exit_code={code_8mb:?}"); + + // Leg 2 is the hard gate: the fix must work. + if !ok_8mb { + anyhow::bail!("FAIL: download did not complete on 8 MiB worker stacks — fix is broken"); + } + // Leg 1 documents the repro. If it *succeeds*, the overflow needs deeper + // nesting than this harness provides — flag loudly but do not fail, the + // fix leg is still proven. + if ok_2mb { + println!( + "WARNING: 2 MiB leg did not crash here; overflow requires the app's extra \ + tauri/ipc nesting. Fix leg still verified." + ); + } else { + println!("repro confirmed: 2 MiB worker stack dies, matching the crash report"); + } + println!("PASS"); + Ok(()) +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index c32e80bbeb..0e83f0ac84 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use axum::{ - extract::{Path, Query, State}, + extract::{Path, Query, RawQuery, State}, http::{HeaderMap, StatusCode}, response::Json, }; @@ -25,12 +25,23 @@ use super::{api_error, internal_error, not_found}; /// /// Returns the authenticated public key and an event ID for replay detection. /// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). -fn verify_bridge_auth( +pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, url: &str, body: Option<&[u8]>, require_auth_token: bool, +) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { + verify_bridge_auth_with_options(headers, method, url, body, require_auth_token, false) +} + +pub(crate) fn verify_bridge_auth_with_options( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, + require_auth_token: bool, + require_payload: bool, ) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { // Try NIP-98 first (Authorization: Nostr ) if let Some(auth_str) = headers @@ -51,6 +62,18 @@ fn verify_bridge_auth( .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid NIP-98 event JSON"))?; let event_id_bytes = event.id.to_bytes(); + if require_payload + && !event + .tags + .iter() + .any(|tag| tag.kind() == nostr::TagKind::Payload) + { + return Err(api_error( + StatusCode::UNAUTHORIZED, + "NIP-98: missing payload tag", + )); + } + let pubkey = buzz_auth::verify_nip98_event(&event_json, url, method, body) .map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?; @@ -76,7 +99,7 @@ fn verify_bridge_auth( /// `AppState`, not process-local memory. Any Redis/guard error fails closed: /// without the shared `SET NX EX` proof, a stateless worker cannot admit the /// NIP-98 request safely. -async fn check_nip98_replay( +pub(crate) async fn check_nip98_replay( state: &AppState, tenant: &TenantContext, event_id_bytes: [u8; 32], @@ -135,7 +158,11 @@ async fn check_nip98_replay_with_guard( /// pass and the relay would proceed against the wrong tenant's auth context), /// and (b) reject every legitimate request whose community host isn't the /// single configured one. Substituting `tenant.host()` closes both directions. -fn nip98_expected_url(config_relay_url: &str, tenant: &TenantContext, path: &str) -> String { +pub(crate) fn nip98_expected_url( + config_relay_url: &str, + tenant: &TenantContext, + path: &str, +) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") { "https" } else { @@ -560,6 +587,8 @@ pub async fn submit_event( check_nip98_replay(&state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); + let event: nostr::Event = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); super::relay_members::enforce_relay_membership( @@ -570,34 +599,6 @@ pub async fn submit_event( ) .await?; - let event: nostr::Event = serde_json::from_slice(&body) - .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; - - // Mesh signaling kinds (24620 status report, 24621 connect request) are - // ephemeral and deliberately absent from ingest_event's per-kind allowlist. - // The desktop's Rust coordinator publishes them via this bridge, so route - // them to the mesh handlers — the HTTP twin of the WS door's special-casing - // in handlers::event. Membership was enforced above; the handlers re-check - // it fail-closed. - let kind_u32 = buzz_core::kind::event_kind_u32(&event); - if kind_u32 == buzz_core::kind::KIND_MESH_STATUS_REPORT - || kind_u32 == buzz_core::kind::KIND_MESH_CONNECT_REQUEST - { - let event_id = event.id.to_hex(); - return match crate::handlers::mesh_signaling::handle_mesh_event_http( - &state, &tenant, &pubkey, &event, - ) - .await - { - Ok(()) => Ok(Json(serde_json::json!({ - "event_id": event_id, - "accepted": true, - "message": "", - }))), - Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)), - }; - } - let auth = IngestAuth::Http { pubkey, scopes: buzz_auth::Scope::all_known(), // Pure Nostr: full scopes, channel access via membership @@ -1635,6 +1636,203 @@ async fn synthesize_presence( Some(events) } +// ── Moderation queue reads (L6 — Quinn) ─────────────────────────────────────── +// +// Mod-only structured rows (`moderation_reports`/`moderation_actions`/ +// `community_bans`) are not nostr events, so they are served over dedicated +// NIP-98-authed GET endpoints rather than the REQ/`/query` path (which would +// force a synthetic event shape and thread a privileged branch onto the shared +// read hot path). Gated on `ModerationAction::ViewQueue` via the one capability +// helper — never an inline role check. Host-scoped: community from the request +// host, no channel context (queue reads are community-wide). + +/// Shared prelude for a moderation read: bind tenant, verify NIP-98 GET auth, +/// replay-check, and confirm the caller may view the queue. +/// +/// `raw_query` is the request's raw query string (from [`axum::extract::RawQuery`]), +/// e.g. `Some("limit=20&status=open")`. NIP-98 signs the *full* request URL, so the +/// client's `u` tag includes any query string; the expected URL reconstructed here +/// must therefore append the same query verbatim or query-bearing reads +/// (`reports?limit=…`, `audit?limit=…`) 401 on a URL mismatch. Query-less reads +/// (`restricted`) pass `None` and keep the bare-path expectation. The verbatim +/// request query is used (not a re-serialized parse) so the match stays byte-exact +/// with what the client signed regardless of param order or encoding. +async fn authorize_moderation_read( + state: &Arc, + headers: &HeaderMap, + path: &str, + raw_query: Option<&str>, +) -> Result)> { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let path_with_query = match raw_query { + Some(q) if !q.is_empty() => format!("{path}?{q}"), + _ => path.to_string(), + }; + let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let (pubkey, event_id_bytes) = + verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + check_nip98_replay(state, &tenant, event_id_bytes).await?; + let pubkey_bytes = pubkey.to_bytes().to_vec(); + + crate::handlers::moderation_authz::authorize_moderation_action( + &tenant, + state, + &pubkey_bytes, + None, + crate::handlers::moderation_authz::ModerationTarget::None, + crate::handlers::moderation_authz::ModerationAction::ViewQueue, + ) + .await + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access required", + ) + })?; + + Ok(tenant) +} + +/// Cap on rows returned by a single moderation read. +const MODERATION_READ_LIMIT: i64 = 500; + +/// Optional `?status=` and `?limit=` query for moderation reads. +#[derive(serde::Deserialize, Default)] +pub struct ModerationReadQuery { + status: Option, + limit: Option, +} + +fn clamp_limit(requested: Option) -> i64 { + requested + .filter(|n| *n > 0) + .map(|n| n.min(MODERATION_READ_LIMIT)) + .unwrap_or(MODERATION_READ_LIMIT) +} + +/// `GET /moderation/reports` — the moderation queue (NIP-98 + mod-authz). +pub async fn moderation_reports( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(q): Query, +) -> Result, (StatusCode, Json)> { + let tenant = authorize_moderation_read( + &state, + &headers, + "/moderation/reports", + raw_query.as_deref(), + ) + .await?; + let rows = state + .db + .list_moderation_reports( + tenant.community(), + q.status.as_deref(), + clamp_limit(q.limit), + ) + .await + .map_err(|e| internal_error(&format!("list reports: {e}")))?; + Ok(Json(Value::Array(rows.iter().map(report_json).collect()))) +} + +/// `GET /moderation/audit` — the moderation audit log (NIP-98 + mod-authz). +pub async fn moderation_audit( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(q): Query, +) -> Result, (StatusCode, Json)> { + let tenant = + authorize_moderation_read(&state, &headers, "/moderation/audit", raw_query.as_deref()) + .await?; + let rows = state + .db + .list_moderation_actions(tenant.community(), clamp_limit(q.limit)) + .await + .map_err(|e| internal_error(&format!("list actions: {e}")))?; + Ok(Json(Value::Array(rows.iter().map(action_json).collect()))) +} + +/// `GET /moderation/restricted` — currently banned/timed-out members. +pub async fn moderation_restricted( + State(state): State>, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + let tenant = + authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await?; + let rows = state + .db + .list_community_restrictions(tenant.community()) + .await + .map_err(|e| internal_error(&format!("list restrictions: {e}")))?; + Ok(Json(Value::Array(rows.iter().map(ban_json).collect()))) +} + +fn report_json(r: &buzz_db::moderation::ReportRecord) -> Value { + let (target_kind, target) = match &r.target { + buzz_db::moderation::ReportTarget::Event(id) => ("event", hex::encode(id)), + buzz_db::moderation::ReportTarget::Pubkey(pk) => ("pubkey", hex::encode(pk)), + buzz_db::moderation::ReportTarget::Blob(sha) => ("blob", hex::encode(sha)), + }; + serde_json::json!({ + "id": r.id, + "report_event_id": hex::encode(&r.report_event_id), + "reporter_pubkey": hex::encode(&r.reporter_pubkey), + "target_kind": target_kind, + "target": target, + "channel_id": r.channel_id, + "report_type": r.report_type, + "note": r.note, + "status": r.status, + "resolved_by": r.resolved_by.as_ref().map(hex::encode), + "resolved_at": r.resolved_at, + "action_id": r.action_id, + "created_at": r.created_at, + }) +} + +fn action_json(a: &buzz_db::moderation::ActionRecord) -> Value { + serde_json::json!({ + "id": a.id, + "actor_pubkey": hex::encode(&a.actor_pubkey), + "action": a.action, + "target_pubkey": a.target_pubkey.as_ref().map(hex::encode), + "target_event_id": a.target_event_id.as_ref().map(hex::encode), + "channel_id": a.channel_id, + "reason_code": a.reason_code, + "public_reason": a.public_reason, + "private_reason": a.private_reason, + "matched_principal": a.matched_principal, + "created_at": a.created_at, + }) +} + +fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value { + serde_json::json!({ + "pubkey": hex::encode(&b.pubkey), + "banned": b.banned, + "ban_expires_at": b.ban_expires_at, + "ban_reason": b.ban_reason, + "muted_until": b.muted_until, + "mute_reason": b.mute_reason, + "actor_pubkey": hex::encode(&b.actor_pubkey), + "updated_at": b.updated_at, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1787,9 +1985,9 @@ mod tests { struct AlwaysErrGuard; impl Nip98ReplayGuard for AlwaysErrGuard { - fn try_mark<'a>( + fn try_mark_in_scope<'a>( &'a self, - _ctx: &'a buzz_core::TenantContext, + _scope: &'a str, _event_id: &'a EventId, _ttl_secs: u64, ) -> Pin> + Send + 'a>> { @@ -1900,6 +2098,34 @@ mod tests { ); } + #[test] + fn verify_bridge_auth_can_require_payload_tag_for_json_body_endpoints() { + let keys = Keys::generate(); + let signed_url = "https://host-a.example/operator/communities"; + let event_json = build_nip98_event_json(&keys, signed_url, "POST"); + let headers = nip98_auth_headers(&event_json); + + let (status, body) = verify_bridge_auth_with_options( + &headers, + "POST", + signed_url, + Some(br#"{"host":"created.example"}"#), + true, + true, + ) + .expect_err("body-bearing operator requests must require a payload tag"); + + assert_eq!(status, StatusCode::UNAUTHORIZED); + let msg = body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert!( + msg.contains("missing payload tag"), + "rejection should explain the payload binding failure; got body = {body:?}" + ); + } + /// Positive control for the cross-host test: a NIP-98 event signed for /// host A MUST be accepted at a request whose tenant resolved to host A. /// Without this, the cross-host test could be passing vacuously (e.g. if @@ -1927,6 +2153,133 @@ mod tests { ); } + /// Mirror of the query-reconstruction `authorize_moderation_read` performs + /// before calling [`nip98_expected_url`], so the tests below pin the exact + /// seam without a DB harness. Kept in lockstep with the production match arm. + fn moderation_read_expected_url( + config_relay_url: &str, + tenant: &TenantContext, + path: &str, + raw_query: Option<&str>, + ) -> String { + let path_with_query = match raw_query { + Some(q) if !q.is_empty() => format!("{path}?{q}"), + _ => path.to_string(), + }; + nip98_expected_url(config_relay_url, tenant, &path_with_query) + } + + /// L7 read-auth blocker (Wren, #1591 sweep): the CLI signs the *full* + /// request URL — including `?limit=…&status=…` — but the relay used to + /// reconstruct the expected URL from the bare path only, so + /// `buzz moderation reports` / `audit` 401'd on a NIP-98 URL mismatch in + /// normal use. This pins that a query-bearing GET verifies iff the expected + /// URL carries the same query verbatim. Bites if the query is ever dropped + /// from `authorize_moderation_read`'s expected-URL reconstruction. + #[test] + fn moderation_read_query_bearing_nip98_event_verifies_with_matching_query() { + let keys = Keys::generate(); + // CLI signs the URL it actually requests, query and all. + let signed_url = "https://host-a.example/moderation/reports?limit=20&status=open"; + let event_json = build_nip98_event_json(&keys, signed_url, "GET"); + let headers = nip98_auth_headers(&event_json); + + let tenant_a = fresh_tenant("host-a.example"); + let expected_url = moderation_read_expected_url( + "wss://config-host.example", + &tenant_a, + "/moderation/reports", + Some("limit=20&status=open"), + ); + + let (pubkey, _event_id_bytes) = + verify_bridge_auth(&headers, "GET", &expected_url, None, true) + .expect("query-bearing moderation read must verify against the same query"); + assert_eq!(pubkey, keys.public_key()); + } + + /// Anti-regression control proving the fix is load-bearing: the same + /// query-bearing event MUST be rejected when the expected URL omits the + /// query — the pre-fix behavior. If this ever passes, the relay has + /// silently reverted to bare-path reconstruction. + #[test] + fn moderation_read_query_bearing_nip98_event_rejected_against_bare_path() { + let keys = Keys::generate(); + let signed_url = "https://host-a.example/moderation/reports?limit=20&status=open"; + let event_json = build_nip98_event_json(&keys, signed_url, "GET"); + let headers = nip98_auth_headers(&event_json); + + let tenant_a = fresh_tenant("host-a.example"); + // No query — the broken pre-fix reconstruction. + let bare_url = moderation_read_expected_url( + "wss://config-host.example", + &tenant_a, + "/moderation/reports", + None, + ); + + let (status, body) = verify_bridge_auth(&headers, "GET", &bare_url, None, true) + .expect_err("query-signed event MUST NOT match a bare-path expected URL"); + assert_eq!(status, StatusCode::UNAUTHORIZED); + let msg = body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert!( + msg.contains("URL mismatch"), + "rejection must be a URL mismatch; got body = {body:?}" + ); + } + + /// `audit?limit=20` — the second query-bearing read path — verifies the + /// same way. Pins that the reconstruction is generic over the path, not + /// special-cased to `reports`. + #[test] + fn moderation_read_audit_query_bearing_nip98_event_verifies() { + let keys = Keys::generate(); + let signed_url = "https://host-a.example/moderation/audit?limit=20"; + let event_json = build_nip98_event_json(&keys, signed_url, "GET"); + let headers = nip98_auth_headers(&event_json); + + let tenant_a = fresh_tenant("host-a.example"); + let expected_url = moderation_read_expected_url( + "wss://config-host.example", + &tenant_a, + "/moderation/audit", + Some("limit=20"), + ); + + let (pubkey, _event_id_bytes) = + verify_bridge_auth(&headers, "GET", &expected_url, None, true) + .expect("audit query-bearing read must verify"); + assert_eq!(pubkey, keys.public_key()); + } + + /// `restricted` has no query and passes `None`, so its expected URL stays + /// the bare path — a query-less signed event verifies. Pins Wren's + /// "preserve restricted no-query behavior" checklist item. + #[test] + fn moderation_read_restricted_no_query_still_verifies() { + let keys = Keys::generate(); + let signed_url = "https://host-a.example/moderation/restricted"; + let event_json = build_nip98_event_json(&keys, signed_url, "GET"); + let headers = nip98_auth_headers(&event_json); + + let tenant_a = fresh_tenant("host-a.example"); + let expected_url = moderation_read_expected_url( + "wss://config-host.example", + &tenant_a, + "/moderation/restricted", + None, + ); + assert_eq!(expected_url, "https://host-a.example/moderation/restricted"); + + let (pubkey, _event_id_bytes) = + verify_bridge_auth(&headers, "GET", &expected_url, None, true) + .expect("query-less restricted read must verify against the bare path"); + assert_eq!(pubkey, keys.public_key()); + } + /// `nip98_expected_url` derives host from `tenant`, not from /// `config_relay_url`. Pin both directions: changing the tenant's host /// changes the output; changing the config's host does NOT. diff --git a/crates/buzz-relay/src/api/git/cas_publish.rs b/crates/buzz-relay/src/api/git/cas_publish.rs index bfc34e117c..ee5b6ab273 100644 --- a/crates/buzz-relay/src/api/git/cas_publish.rs +++ b/crates/buzz-relay/src/api/git/cas_publish.rs @@ -225,15 +225,16 @@ impl ParentState { /// HEAD or no HEAD yields an empty string. async fn snapshot_workspace_state( repo_path: &Path, + scratch_dir: &Path, ) -> Result<(BTreeMap, String), CasError> { const MAX_REF_SNAPSHOT_BYTES: u64 = 4 * 1024 * 1024; - let refs_stdout_tmp = tempfile::NamedTempFile::new() + let refs_stdout_tmp = tempfile::NamedTempFile::new_in(scratch_dir) .map_err(|e| CasError::PackCapture(format!("for-each-ref stdout tempfile: {e}")))?; let refs_stdout_file = refs_stdout_tmp .reopen() .map_err(|e| CasError::PackCapture(format!("for-each-ref stdout reopen: {e}")))?; - let refs_stderr_tmp = tempfile::NamedTempFile::new() + let refs_stderr_tmp = tempfile::NamedTempFile::new_in(scratch_dir) .map_err(|e| CasError::PackCapture(format!("for-each-ref stderr tempfile: {e}")))?; let refs_stderr_file = refs_stderr_tmp .reopen() @@ -325,9 +326,11 @@ async fn write_idx_sidecar( store: &GitStore, pack_key: &str, pack_bytes: &[u8], + scratch_dir: &Path, ) -> Result<(), CasError> { let pack_digest = digest_from_pack_key(pack_key)?; - let tempdir = TempDir::new().map_err(|e| CasError::PackCapture(format!("idx tempdir: {e}")))?; + let tempdir = TempDir::new_in(scratch_dir) + .map_err(|e| CasError::PackCapture(format!("idx tempdir in {scratch_dir:?}: {e}")))?; let pack_path = tempdir.path().join(format!("pack-{pack_digest}.pack")); tokio::fs::write(&pack_path, pack_bytes) .await @@ -380,6 +383,7 @@ async fn capture_pack( refs_before: &BTreeMap, refs_after: &BTreeMap, max_pack_bytes: u64, + scratch_dir: &Path, ) -> Result>, CasError> { // Build rev-spec stdin: positive new tips, negative old tips. // Deduplicate against the same-oid case — no point feeding `X ^X`. @@ -401,12 +405,12 @@ async fn capture_pack( stdin_lines.push('\n'); } - let stdout_tmp = tempfile::NamedTempFile::new() + let stdout_tmp = tempfile::NamedTempFile::new_in(scratch_dir) .map_err(|e| CasError::PackCapture(format!("pack-objects stdout tempfile: {e}")))?; let stdout_file = stdout_tmp .reopen() .map_err(|e| CasError::PackCapture(format!("pack-objects stdout reopen: {e}")))?; - let stderr_tmp = tempfile::NamedTempFile::new() + let stderr_tmp = tempfile::NamedTempFile::new_in(scratch_dir) .map_err(|e| CasError::PackCapture(format!("pack-objects stderr tempfile: {e}")))?; let stderr_file = stderr_tmp .reopen() @@ -558,10 +562,17 @@ pub async fn cas_publish( ) -> Result { let pkey = pointer_key(ctx.community(), owner, repo); + // Hydrated repositories are direct children of the configured Git scratch + // root. Reuse that parent for publication tempfiles so they remain on the + // mounted scratch volume without adding another independent path argument. + let scratch_dir = repo_path + .parent() + .ok_or_else(|| CasError::PackCapture("repository path has no scratch parent".into()))?; + // Snapshot post-receive-pack state from disk. `parent_state.parent.refs` // are the refs the workspace was hydrated from — `pack-objects --revs` // below uses them as the "negative" set to produce the delta pack. - let (refs_after, head_observed) = snapshot_workspace_state(repo_path).await?; + let (refs_after, head_observed) = snapshot_workspace_state(repo_path, scratch_dir).await?; // HEAD fallback: a bare repo serving pushes shouldn't have detached // HEAD, but if `git symbolic-ref` failed (or returned empty), inherit @@ -583,6 +594,7 @@ pub async fn cas_publish( &parent_state.parent.refs, &refs_after, limits.max_pack_bytes, + scratch_dir, ) .await?; let new_pack_key = if let Some(bytes) = pack_bytes { @@ -599,7 +611,7 @@ pub async fn cas_publish( } debug!(bytes = bytes.len(), "captured push pack"); let pack_key = store.put_pack(&bytes).await?; - if let Err(e) = write_idx_sidecar(store, &pack_key, &bytes).await { + if let Err(e) = write_idx_sidecar(store, &pack_key, &bytes, scratch_dir).await { warn!( pack_key = %pack_key, error = %e, diff --git a/crates/buzz-relay/src/api/git/hydrate.rs b/crates/buzz-relay/src/api/git/hydrate.rs index 1d8447ff9e..e9b8d5155b 100644 --- a/crates/buzz-relay/src/api/git/hydrate.rs +++ b/crates/buzz-relay/src/api/git/hydrate.rs @@ -105,6 +105,7 @@ pub async fn hydrate_for_read( ctx: &TenantContext, owner: &str, repo: &str, + scratch_dir: &Path, max_pack_bytes: u64, max_repo_bytes: u64, ) -> Result, HydrateError> { @@ -112,7 +113,14 @@ pub async fn hydrate_for_read( return Ok(None); }; Ok(Some( - materialize_manifest(store, &manifest, max_pack_bytes, max_repo_bytes).await?, + materialize_manifest( + store, + &manifest, + scratch_dir, + max_pack_bytes, + max_repo_bytes, + ) + .await?, )) } @@ -158,13 +166,20 @@ pub async fn hydrate_for_write( ctx: &TenantContext, owner: &str, repo: &str, + scratch_dir: &Path, max_pack_bytes: u64, max_repo_bytes: u64, ) -> Result<(HydratedRepo, ParentState), HydrateError> { match load_pointer(store, ctx, owner, repo).await? { Some((etag, digest, manifest)) => { - let repo = - materialize_manifest(store, &manifest, max_pack_bytes, max_repo_bytes).await?; + let repo = materialize_manifest( + store, + &manifest, + scratch_dir, + max_pack_bytes, + max_repo_bytes, + ) + .await?; let parent = ParentState::from_loaded(etag, digest, manifest); Ok((repo, parent)) } @@ -172,8 +187,8 @@ pub async fn hydrate_for_write( // First push: empty bare repo. No packs to fetch, no refs to // install. `receive-pack` will accept whatever the client // sends; `cas_publish` will use `If-None-Match: *`. - let tempdir = - TempDir::new().map_err(|e| HydrateError::Hydrate(format!("tempdir: {e}")))?; + let tempdir = TempDir::new_in(scratch_dir) + .map_err(|e| HydrateError::Hydrate(format!("tempdir in {scratch_dir:?}: {e}")))?; let path = tempdir.path().to_path_buf(); run_git(&path, &["init", "--bare", "--quiet"]).await?; Ok(( @@ -242,11 +257,13 @@ async fn get_verified_limited( async fn materialize_manifest( store: &GitStore, manifest: &Manifest, + scratch_dir: &Path, max_pack_bytes: u64, max_repo_bytes: u64, ) -> Result { // Init bare repo. - let tempdir = TempDir::new().map_err(|e| HydrateError::Hydrate(format!("tempdir: {e}")))?; + let tempdir = TempDir::new_in(scratch_dir) + .map_err(|e| HydrateError::Hydrate(format!("tempdir in {scratch_dir:?}: {e}")))?; let path = tempdir.path().to_path_buf(); run_git(&path, &["init", "--bare", "--quiet"]).await?; @@ -443,6 +460,26 @@ mod tests { ) } + #[tokio::test] + async fn materialized_repo_is_created_under_configured_scratch_dir() { + let scratch = TempDir::new().unwrap(); + let store = GitStore::new("http://localhost:9000", "x", "x", "x", "us-east-1") + .expect("construct store"); + let manifest = Manifest { + version: 1, + head: "refs/heads/main".into(), + refs: BTreeMap::new(), + packs: Vec::new(), + parent: None, + }; + + let hydrated = materialize_manifest(&store, &manifest, scratch.path(), u64::MAX, u64::MAX) + .await + .expect("materialize empty repo"); + + assert!(hydrated.path().starts_with(scratch.path())); + } + // -------- Live MinIO + real git roundtrip ---------------------------------- // // Run manually: @@ -557,10 +594,12 @@ mod tests { } // Hydrate. - let hydrated = hydrate_for_read(&st, &ctx, &owner, repo, u64::MAX, u64::MAX) - .await - .expect("hydrate") - .expect("hydrate Some"); + let scratch = TempDir::new().unwrap(); + let hydrated = + hydrate_for_read(&st, &ctx, &owner, repo, scratch.path(), u64::MAX, u64::MAX) + .await + .expect("hydrate") + .expect("hydrate Some"); eprintln!("hydrated to {}", hydrated.path().display()); // The hydrated repo must list the same ref with the same oid. @@ -630,9 +669,18 @@ mod tests { let st = store(); let owner = format!("nope-{}", uuid::Uuid::new_v4()); let ctx = tenant(); - let result = hydrate_for_read(&st, &ctx, &owner, "ghost", u64::MAX, u64::MAX) - .await - .expect("ok"); + let scratch = TempDir::new().unwrap(); + let result = hydrate_for_read( + &st, + &ctx, + &owner, + "ghost", + scratch.path(), + u64::MAX, + u64::MAX, + ) + .await + .expect("ok"); assert!(result.is_none(), "missing pointer must surface as None"); } @@ -677,10 +725,19 @@ mod tests { super::super::store::CasOutcome::LostRace => panic!("first INM* must win"), } - let hydrated = hydrate_for_read(&st, &ctx, &owner, "void", u64::MAX, u64::MAX) - .await - .expect("hydrate") - .expect("hydrate Some"); + let scratch = TempDir::new().unwrap(); + let hydrated = hydrate_for_read( + &st, + &ctx, + &owner, + "void", + scratch.path(), + u64::MAX, + u64::MAX, + ) + .await + .expect("hydrate") + .expect("hydrate Some"); // HEAD points where the manifest said. let head_file = tokio::fs::read_to_string(hydrated.path().join("HEAD")) diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 8596641bdc..f18ca5ddb4 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -581,6 +581,7 @@ async fn info_refs_subprocess( tenant, ¶ms.owner, ¶ms.repo, + &state.config.git_repo_path, state.config.git_max_pack_bytes, state.config.git_max_repo_bytes, ) @@ -596,11 +597,11 @@ async fn info_refs_subprocess( // "receive-pack" (without the "git-" prefix). let git_subcmd = service.strip_prefix("git-").unwrap_or(service); - let stdout_tmp = tempfile::NamedTempFile::new().map_err(|e| { + let stdout_tmp = tempfile::NamedTempFile::new_in(&state.config.git_repo_path).map_err(|e| { error!(error = %e, "git info_refs stdout tempfile failed"); (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response() })?; - let stderr_tmp = tempfile::NamedTempFile::new().map_err(|e| { + let stderr_tmp = tempfile::NamedTempFile::new_in(&state.config.git_repo_path).map_err(|e| { error!(error = %e, "git info_refs stderr tempfile failed"); (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response() })?; @@ -712,6 +713,7 @@ pub async fn upload_pack( &auth.tenant, ¶ms.owner, ¶ms.repo, + &state.config.git_repo_path, state.config.git_max_pack_bytes, state.config.git_max_repo_bytes, ) @@ -799,6 +801,7 @@ pub async fn receive_pack( &auth.tenant, ¶ms.owner, ¶ms.repo, + &state.config.git_repo_path, state.config.git_max_pack_bytes, state.config.git_max_repo_bytes, ) @@ -848,6 +851,7 @@ pub async fn receive_pack( "receive-pack", body, &hook_env, + &state.config.git_repo_path, RECEIVE_PACK_MAX_OUTPUT_BYTES, ) .await?; @@ -900,13 +904,14 @@ async fn run_git_at( service: &str, body: Body, extra_env: &[(&str, String)], + scratch_dir: &Path, max_output_bytes: u64, ) -> Result { - let stdout_tmp = tempfile::NamedTempFile::new().map_err(|e| { + let stdout_tmp = tempfile::NamedTempFile::new_in(scratch_dir).map_err(|e| { error!(error = %e, service = %service, "git stdout tempfile failed"); (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response() })?; - let stderr_tmp = tempfile::NamedTempFile::new().map_err(|e| { + let stderr_tmp = tempfile::NamedTempFile::new_in(scratch_dir).map_err(|e| { error!(error = %e, service = %service, "git stderr tempfile failed"); (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response() })?; diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs new file mode 100644 index 0000000000..3b09e2a388 --- /dev/null +++ b/crates/buzz-relay/src/api/invites.rs @@ -0,0 +1,613 @@ +//! Relay invite HTTP API — mint and claim stateless invite codes. +//! +//! Routes (both NIP-98 signed, outside the Nostr event data plane): +//! +//! - `POST /api/invites` — mint an invite code. Caller must hold the `owner` +//! or `admin` role in the tenant community (mirrors the kind:9030 authz). +//! - `POST /api/invites/claim` — claim an invite code. Deliberately **exempt +//! from the relay-membership gate**: the whole point is that the caller is +//! not a member yet. NIP-98 proves control of the joining pubkey; the HMAC +//! on the code proves an admin authorized the join. +//! +//! Token format, key derivation, and security trade-offs live in +//! [`crate::invite_token`]. + +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde::Deserialize; +use serde_json::Value; + +use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; +use crate::invite_token::{self, DEFAULT_INVITE_TTL_SECS}; +use crate::state::AppState; + +use super::{api_error, bridge, internal_error}; + +/// Fixed-window size for the per-pubkey claim rate limiter. +pub(crate) const CLAIM_RATE_WINDOW: Duration = Duration::from_secs(60); +/// Max claim attempts per pubkey per window. Claims are idempotent and a real +/// user performs exactly one, so this only bounds brute-force probing. +const CLAIM_RATE_LIMIT: u32 = 10; +/// Maximum distinct pubkeys retained by the process-local claim limiter. +/// NIP-98 proves key ownership, not that a key is costly to create, so this +/// bound is required in addition to expiry. +pub(crate) const CLAIM_RATE_CACHE_CAPACITY: u64 = 10_000; + +/// Body for `POST /api/invites`. +#[derive(Debug, Default, Deserialize)] +pub struct MintInviteRequest { + /// Requested lifetime in seconds. Clamped to + /// [`invite_token::MAX_INVITE_TTL_SECS`]; defaults to 72 h. + #[serde(default)] + pub ttl_secs: Option, +} + +/// Body for `POST /api/invites/claim`. +#[derive(Debug, Deserialize)] +pub struct ClaimInviteRequest { + /// The invite code to redeem. + pub code: String, +} + +/// Shared prelude: bind the tenant from the Host header and verify the NIP-98 +/// signature + replay for `path`. +async fn authenticate( + state: &Arc, + headers: &HeaderMap, + path: &str, + body: &[u8], +) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + headers, + "POST", + &url, + Some(body), + true, // invites always require NIP-98; no X-Pubkey dev fallback + true, // POST bodies must be covered by a payload tag + )?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + + Ok((tenant, pubkey)) +} + +/// Mint an invite code — `POST /api/invites`, NIP-98 signed by an owner/admin. +/// +/// Returns the code, its expiry, and a shareable landing-page URL on the +/// tenant host. +pub async fn mint_invite( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites", &body).await?; + + // Authz mirrors kind:9030 (add member): owner or admin only. + let sender_hex = pubkey.to_hex(); + let member = state + .db + .get_relay_member(tenant.community(), &sender_hex) + .await + .map_err(|e| internal_error(&format!("invite mint role lookup: {e}")))?; + let role = member.map(|m| m.role).unwrap_or_default(); + if role != "owner" && role != "admin" { + return Err(api_error( + StatusCode::FORBIDDEN, + "only relay owners and admins can create invites", + )); + } + + let request: MintInviteRequest = if body.is_empty() { + MintInviteRequest::default() + } else { + serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid invite JSON: {e}"), + ) + })? + }; + + let key = invite_token::derive_invite_key(&state.relay_keypair); + let ttl = request.ttl_secs.unwrap_or(DEFAULT_INVITE_TTL_SECS); + let (code, expires_at) = invite_token::mint_invite(&key, tenant.community(), ttl); + + // Same TLS-posture logic as nip98_expected_url: wss deployments get an + // https landing page URL, ws dev/test deployments get http. + let scheme = if state.config.relay_url.trim_start().starts_with("wss://") { + "https" + } else { + "http" + }; + + tracing::info!( + community = %tenant.community(), + minted_by = %sender_hex, + expires_at, + "relay invite minted" + ); + + Ok(Json(serde_json::json!({ + "code": code, + "expires_at": expires_at, + "url": format!("{scheme}://{}/invite/{}", tenant.host(), code), + }))) +} + +/// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the +/// *joining* pubkey. Exempt from the relay-membership gate by design. +pub async fn claim_invite( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites/claim", &body).await?; + + if claim_rate_limited(&state, tenant.community(), &pubkey) { + return Err(api_error( + StatusCode::TOO_MANY_REQUESTS, + "too many invite claim attempts, slow down", + )); + } + + let request: ClaimInviteRequest = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid claim JSON: {e}")))?; + + let key = invite_token::derive_invite_key(&state.relay_keypair); + let payload = invite_token::verify_invite(&key, tenant.community(), &request.code).map_err( + |e| match e { + // Expired is post-MAC: revealing it helps the UX without helping a forger. + invite_token::InviteError::Expired => { + api_error(StatusCode::FORBIDDEN, "invite_expired") + } + // Everything else stays coarse so the endpoint is a poor oracle. + _ => api_error(StatusCode::FORBIDDEN, "invite_invalid"), + }, + )?; + + let claimer_hex = pubkey.to_hex(); + let was_inserted = state + .db + .add_relay_member(tenant.community(), &claimer_hex, &payload.r, Some("invite")) + .await + .map_err(|e| internal_error(&format!("invite claim insert: {e}")))?; + + if was_inserted { + tracing::info!( + community = %tenant.community(), + member = %claimer_hex, + "relay member added via invite" + ); + if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { + tracing::warn!("failed to publish NIP-43 member-added delta after claim: {e}"); + } + if let Err(e) = publish_nip43_membership_list(&tenant, &state).await { + tracing::warn!("failed to publish NIP-43 membership list after claim: {e}"); + } + } + + Ok(Json(serde_json::json!({ + "status": if was_inserted { "joined" } else { "already_member" }, + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": payload.r, + }))) +} + +/// Fixed-window rate limit on claim attempts, keyed by community and claimer +/// pubkey so traffic for one tenant cannot consume another tenant's allowance. +/// +/// Entries expire after one window and the cache has a hard capacity. Both are +/// important because a pre-membership caller can cheaply create fresh Nostr +/// keypairs; retaining one immortal entry per key would make the limiter itself +/// an unbounded-memory denial-of-service vector. +fn claim_rate_limited( + state: &AppState, + community: buzz_core::tenant::CommunityId, + pubkey: &nostr::PublicKey, +) -> bool { + claim_key_rate_limited( + &state.invite_claim_rate_limiter, + (community, pubkey.to_bytes()), + ) +} + +fn claim_key_rate_limited( + cache: &moka::sync::Cache>, + key: crate::state::ScopedPubkeyKey, +) -> bool { + let counter = cache.get_with(key, || Arc::new(std::sync::atomic::AtomicU32::new(0))); + counter.fetch_add(1, Ordering::Relaxed) >= CLAIM_RATE_LIMIT +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use super::{claim_key_rate_limited, CLAIM_RATE_LIMIT}; + use axum::{ + body::{to_bytes, Body}, + http::{header, Request, StatusCode}, + }; + use base64::Engine; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use serde_json::Value; + use sha2::{Digest, Sha256}; + use tower::ServiceExt; + use uuid::Uuid; + + use crate::router::build_router; + use crate::state::AppState; + + struct AlwaysFreshReplayGuard; + + impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async { Ok(true) }) + } + } + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + fn claim_cache( + capacity: u64, + ttl: Duration, + ) -> moka::sync::Cache> { + moka::sync::Cache::builder() + .max_capacity(capacity) + .time_to_live(ttl) + .build() + } + + #[test] + fn claim_limiter_rejects_after_limit() { + let cache = claim_cache(100, Duration::from_secs(60)); + let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), [7; 32]); + + for _ in 0..CLAIM_RATE_LIMIT { + assert!(!claim_key_rate_limited(&cache, key)); + } + assert!(claim_key_rate_limited(&cache, key)); + } + + #[test] + fn claim_limiter_expires_entries() { + let cache = claim_cache(100, Duration::from_millis(10)); + let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), [8; 32]); + assert!(!claim_key_rate_limited(&cache, key)); + assert!(cache.get(&key).is_some()); + + std::thread::sleep(Duration::from_millis(25)); + cache.run_pending_tasks(); + + assert!(cache.get(&key).is_none()); + assert!(!claim_key_rate_limited(&cache, key)); + } + + #[test] + fn claim_limiter_isolates_communities_for_same_pubkey() { + let cache = claim_cache(100, Duration::from_secs(60)); + let pubkey = [9; 32]; + let community_a = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xAAAA)); + let community_b = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xBBBB)); + + for _ in 0..CLAIM_RATE_LIMIT { + assert!(!claim_key_rate_limited(&cache, (community_a, pubkey))); + } + assert!(claim_key_rate_limited(&cache, (community_a, pubkey))); + assert!(!claim_key_rate_limited(&cache, (community_b, pubkey))); + } + + #[test] + fn claim_limiter_bounds_distinct_pubkeys() { + let capacity = 10; + let cache = claim_cache(capacity, Duration::from_secs(60)); + for id in 0..100_u64 { + let mut pubkey = [0; 32]; + pubkey[..8].copy_from_slice(&id.to_le_bytes()); + let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), pubkey); + assert!(!claim_key_rate_limited(&cache, key)); + } + cache.run_pending_tasks(); + + assert!(cache.entry_count() <= capacity); + } + + fn nip98_auth_header(keys: &Keys, url: &str, body: &[u8]) -> String { + let hash: [u8; 32] = Sha256::digest(body).into(); + let tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", "POST"]).expect("method tag"), + Tag::parse(["payload", hex::encode(hash).as_str()]).expect("payload tag"), + ]; + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); + let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes()); + format!("Nostr {encoded}") + } + + /// Build a closed-relay (`require_relay_membership = true`) test state with + /// a fresh community on `host`; returns `None` when Postgres is unavailable. + async fn invite_test_state(host: &str) -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = TEST_DB_URL.to_string(); + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_url = format!("wss://{host}"); + // The claim route must work on relays where membership is enforced — + // that is the entire point of an invite. + config.require_relay_membership = true; + + let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + db.ensure_configured_community(host).await.ok()?; + + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + async fn post_json( + state: Arc, + host: &str, + path: &str, + keys: &Keys, + body: String, + ) -> axum::response::Response { + let url = format!("https://{host}{path}"); + let auth = nip98_auth_header(keys, &url, body.as_bytes()); + build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header(header::HOST, host) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response") + } + + async fn read_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("read response body"); + serde_json::from_slice(&bytes).expect("response JSON") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_mints_and_new_pubkey_claims() { + let host = format!("invites-{}.example", Uuid::new_v4().simple()); + let owner = Keys::generate(); + let joiner = Keys::generate(); + let Some(state) = invite_test_state(&host).await else { + return; + }; + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + let community_id = community.id; + state + .db + .add_relay_member(community_id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + + // Mint. + let response = post_json( + state.clone(), + &host, + "/api/invites", + &owner, + "{}".to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + let code = json.get("code").and_then(Value::as_str).expect("code"); + let url = json.get("url").and_then(Value::as_str).expect("url"); + assert!(url.contains("/invite/"), "unexpected url: {url}"); + + // Claim on a closed relay by a pubkey that is not yet a member. + let claim_body = serde_json::json!({ "code": code }).to_string(); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + claim_body.clone(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!(json.get("status").and_then(Value::as_str), Some("joined")); + assert_eq!(json.get("role").and_then(Value::as_str), Some("member")); + + let member = state + .db + .get_relay_member(community_id, &joiner.public_key().to_hex()) + .await + .expect("member lookup") + .expect("joiner is now a member"); + assert_eq!(member.role, "member"); + + // Second claim is idempotent. + let response = post_json(state, &host, "/api/invites/claim", &joiner, claim_body).await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!( + json.get("status").and_then(Value::as_str), + Some("already_member") + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn non_admin_cannot_mint() { + let host = format!("invites-{}.example", Uuid::new_v4().simple()); + let member = Keys::generate(); + let outsider = Keys::generate(); + let Some(state) = invite_test_state(&host).await else { + return; + }; + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + let community_id = community.id; + state + .db + .add_relay_member(community_id, &member.public_key().to_hex(), "member", None) + .await + .expect("seed member"); + + for keys in [&member, &outsider] { + let response = + post_json(state.clone(), &host, "/api/invites", keys, "{}".to_string()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_rejects_invalid_code() { + let host = format!("invites-{}.example", Uuid::new_v4().simple()); + let joiner = Keys::generate(); + let Some(state) = invite_test_state(&host).await else { + return; + }; + + let body = serde_json::json!({ "code": "garbage.code" }).to_string(); + let response = post_json(state.clone(), &host, "/api/invites/claim", &joiner, body).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let json = read_json(response).await; + assert_eq!( + json.get("error").and_then(Value::as_str), + Some("invite_invalid") + ); + + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + let is_member = state + .db + .is_relay_member(community.id, &joiner.public_key().to_hex()) + .await + .expect("member check"); + assert!(!is_member, "invalid code must not admit anyone"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn code_minted_for_one_community_fails_on_another() { + let host_a = format!("invites-a-{}.example", Uuid::new_v4().simple()); + let host_b = format!("invites-b-{}.example", Uuid::new_v4().simple()); + let owner = Keys::generate(); + let joiner = Keys::generate(); + let Some(state) = invite_test_state(&host_a).await else { + return; + }; + state + .db + .ensure_configured_community(&host_b) + .await + .expect("second community"); + let community_a = state + .db + .lookup_community_by_host(&host_a) + .await + .expect("lookup") + .expect("community a"); + state + .db + .add_relay_member(community_a.id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + + let response = post_json( + state.clone(), + &host_a, + "/api/invites", + &owner, + "{}".to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + let code = json.get("code").and_then(Value::as_str).expect("code"); + + // Present community A's code on community B's host. + let body = serde_json::json!({ "code": code }).to_string(); + let response = post_json(state, &host_b, "/api/invites/claim", &joiner, body).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 9cee7ab72b..62b0fdc39c 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -18,7 +18,7 @@ use axum::{ use base64::Engine; use buzz_audit::{AuditAction, NewAuditEntry}; use buzz_core::tenant::TenantContext; -use buzz_media::{BlobDescriptor, MediaError}; +use buzz_media::{BlobDescriptor, MediaError, UploadAttribution, UploadNetworkInfo}; use crate::state::AppState; @@ -38,19 +38,23 @@ pub(crate) struct AuthenticatedUpload { _upload_permit: UploadPermit, } +struct MediaReadAuth { + tenant: TenantContext, +} + const MEDIA_UPLOAD_RATE_WINDOW: Duration = Duration::from_secs(60); struct UploadPermit { _global: tokio::sync::OwnedSemaphorePermit, - in_flight: Arc>, - pubkey: [u8; 32], + in_flight: Arc>, + key: crate::state::ScopedPubkeyKey, } impl Drop for UploadPermit { fn drop(&mut self) { use dashmap::mapref::entry::Entry; - if let Entry::Occupied(mut entry) = self.in_flight.entry(self.pubkey) { + if let Entry::Occupied(mut entry) = self.in_flight.entry(self.key) { if *entry.get() <= 1 { entry.remove(); } else { @@ -60,8 +64,12 @@ impl Drop for UploadPermit { } } -fn upload_rate_limited(state: &AppState, pubkey: &nostr::PublicKey) -> bool { - let key: [u8; 32] = pubkey.to_bytes(); +fn upload_rate_limited( + state: &AppState, + community_id: buzz_core::CommunityId, + pubkey: &nostr::PublicKey, +) -> bool { + let key = (community_id, pubkey.to_bytes()); let now = Instant::now(); let limit = state.config.media_uploads_per_minute; let mut entry = state @@ -83,6 +91,7 @@ fn upload_rate_limited(state: &AppState, pubkey: &nostr::PublicKey) -> bool { fn acquire_upload_permit( state: &AppState, + community_id: buzz_core::CommunityId, pubkey: &nostr::PublicKey, ) -> Result { let global = state @@ -91,7 +100,7 @@ fn acquire_upload_permit( .try_acquire_owned() .map_err(|_| MediaError::UploadConcurrencyLimitReached)?; - let key: [u8; 32] = pubkey.to_bytes(); + let key = (community_id, pubkey.to_bytes()); let mut in_flight = state.media_uploads_in_flight.entry(key).or_insert(0); if *in_flight >= state.config.media_max_concurrent_uploads_per_pubkey { return Err(MediaError::UploadConcurrencyLimitReached); @@ -102,7 +111,7 @@ fn acquire_upload_permit( Ok(UploadPermit { _global: global, in_flight: Arc::clone(&state.media_uploads_in_flight), - pubkey: key, + key, }) } @@ -185,15 +194,16 @@ impl FromRequestParts> for AuthenticatedUpload { .await .map_err(|_| MediaError::RelayMembershipRequired)?; - if upload_rate_limited(state, &auth_event.pubkey) { + if upload_rate_limited(state, tenant.community(), &auth_event.pubkey) { metrics::counter!("buzz_media_upload_rejections_total", "reason" => "rate_limit") .increment(1); return Err(MediaError::UploadRateLimitExceeded); } - let upload_permit = acquire_upload_permit(state, &auth_event.pubkey).inspect_err(|_| { - metrics::counter!("buzz_media_upload_rejections_total", "reason" => "concurrency") - .increment(1); - })?; + let upload_permit = acquire_upload_permit(state, tenant.community(), &auth_event.pubkey) + .inspect_err(|_| { + metrics::counter!("buzz_media_upload_rejections_total", "reason" => "concurrency") + .increment(1); + })?; Ok(AuthenticatedUpload { auth_event, @@ -203,6 +213,52 @@ impl FromRequestParts> for AuthenticatedUpload { } } +/// Build per-event upload attribution when upload records are enabled +/// (`BUZZ_MEDIA_UPLOAD_RECORDS`). Returns `None` when the feature is off — +/// the upload pipeline then writes no `_uploads/` record at all. +/// +/// - `uploader_name` is the uploader's current display name in the bound +/// community (best-effort label; lookup failure degrades to absent). +/// - `net.ip` is read from the operator-configured trusted edge header +/// (`BUZZ_MEDIA_UPLOAD_IP_HEADER`) and validated as a public IP — +/// fail-empty: missing/malformed/non-public values record nothing. The +/// socket address is never used; behind a sidecar it is meaningless, and a +/// wrong address is worse than none. +/// - `net.port` (optional companion header) is only kept alongside a valid IP. +async fn upload_attribution( + state: &AppState, + auth: &AuthenticatedUpload, + headers: &HeaderMap, +) -> Option { + let cfg = &state.config.media; + if !cfg.upload_records_enabled { + return None; + } + + let uploader_name = state + .db + .get_user(auth.tenant.community(), &auth.auth_event.pubkey.to_bytes()) + .await + .ok() + .flatten() + .and_then(|profile| profile.display_name) + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()); + + let header_value = |name: &Option| { + name.as_deref() + .and_then(|h| headers.get(h)) + .and_then(|v| v.to_str().ok()) + }; + let ip = header_value(&cfg.upload_ip_header).and_then(buzz_media::parse_public_ip); + let port = ip.and(header_value(&cfg.upload_port_header).and_then(buzz_media::parse_port)); + + Some(UploadAttribution { + uploader_name, + net: UploadNetworkInfo { ip, port }, + }) +} + /// PUT /media/upload — Blossom BUD-02 upload. /// /// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body @@ -232,6 +288,8 @@ pub async fn upload_blob( .and_then(|v| v.to_str().ok()) .unwrap_or(""); + let attribution = upload_attribution(&state, &auth, &headers).await; + let mut descriptor = if content_type.starts_with("video/") { // Video path: stream body directly to disk — never fully buffered in RAM. let content_length = headers @@ -245,6 +303,7 @@ pub async fn upload_blob( &auth.auth_event, body.into_data_stream(), content_length, + attribution, ) .await? } else { @@ -274,6 +333,7 @@ pub async fn upload_blob( &auth.tenant, &auth.auth_event, bytes, + attribution, ) .await? } else { @@ -283,6 +343,7 @@ pub async fn upload_blob( &auth.tenant, &auth.auth_event, bytes, + attribution, ) .await? } @@ -301,7 +362,12 @@ pub async fn upload_blob( } _ => "other", }; - metrics::counter!("buzz_media_uploads_total", "mime" => mime_label.to_owned()).increment(1); + metrics::counter!( + "buzz_media_uploads_total", + "mime" => mime_label.to_owned(), + "community" => auth.tenant.host().to_owned() + ) + .increment(1); // Audit via bounded channel — same pattern as event audit. let desc = descriptor.clone(); @@ -369,6 +435,42 @@ async fn bind_media_read_tenant( .map_err(|_| MediaError::NotFound) } +async fn authenticate_media_read( + state: &AppState, + headers: &HeaderMap, + sha256_ext: &str, +) -> Result { + let tenant = bind_media_read_tenant(state, headers).await?; + + if !state.config.require_media_get_auth { + return Ok(MediaReadAuth { tenant }); + } + + let auth_event = extract_blossom_auth(headers)?; + let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); + buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; + + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + crate::api::relay_members::enforce_relay_membership( + state, + tenant.community(), + auth_event.pubkey.as_bytes(), + auth_tag, + ) + .await + .map_err(|_| MediaError::RelayMembershipRequired)?; + + Ok(MediaReadAuth { tenant }) +} + +fn blob_cache_control(require_auth: bool) -> &'static str { + if require_auth { + "private, max-age=31536000, immutable" + } else { + "public, max-age=31536000, immutable" + } +} + /// Whether a path-segment extension is a safe token. /// /// The sidecar's `ext` field is the *authoritative* extension — the serve and @@ -454,7 +556,10 @@ pub async fn get_blob( req_headers: HeaderMap, ) -> Result { validate_media_path(&sha256_ext)?; - let tenant = bind_media_read_tenant(&state, &req_headers).await?; + let require_media_get_auth = state.config.require_media_get_auth; + let media_auth = authenticate_media_read(&state, &req_headers, &sha256_ext).await?; + let tenant = media_auth.tenant; + let cache_control = blob_cache_control(require_media_get_auth); // Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative. let content_type = if sha256_ext.ends_with(".thumb.jpg") { @@ -525,7 +630,7 @@ pub async fn get_blob( .header(header::CONTENT_TYPE, &content_type) .header(header::CONTENT_LENGTH, total.to_string()) .header(header::CONTENT_DISPOSITION, disposition) - .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .header(header::CACHE_CONTROL, cache_control) .header(header::CONTENT_SECURITY_POLICY, "default-src 'none'") .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") .header(header::ACCEPT_RANGES, "bytes") @@ -571,7 +676,7 @@ pub async fn get_blob( .header(header::CONTENT_LENGTH, chunk.len().to_string()) .header(header::CONTENT_DISPOSITION, disposition) .header(header::ACCEPT_RANGES, "bytes") - .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .header(header::CACHE_CONTROL, cache_control) .header(header::CONTENT_SECURITY_POLICY, "default-src 'none'") .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") .body(axum::body::Body::from(chunk)) @@ -637,7 +742,10 @@ pub async fn head_blob( Path(sha256_ext): Path, ) -> Result { validate_media_path(&sha256_ext)?; - let tenant = bind_media_read_tenant(&state, &headers).await?; + let require_media_get_auth = state.config.require_media_get_auth; + let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; + let tenant = media_auth.tenant; + let cache_control = blob_cache_control(require_media_get_auth); // Sidecar gate FIRST — reject before any blob I/O. let content_type = if sha256_ext.ends_with(".thumb.jpg") { @@ -678,7 +786,7 @@ pub async fn head_blob( ("content-type", content_type.as_str()), ("content-length", size_str.as_str()), ("accept-ranges", "bytes"), - ("cache-control", "public, max-age=31536000, immutable"), + ("cache-control", cache_control), ], ) .into_response()) @@ -743,9 +851,244 @@ fn extract_blossom_auth(headers: &HeaderMap) -> Result #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; + + use axum::{ + body::Body, + http::{header, Request, StatusCode}, + }; + use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; + use tower::ServiceExt; + use uuid::Uuid; const VALID_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + async fn test_state() -> Arc { + test_state_with_media_get_auth(false).await + } + + async fn test_state_with_media_get_auth(require_media_get_auth: bool) -> Arc { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.require_media_get_auth = require_media_get_auth; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.media_uploads_per_minute = 1; + config.media_max_concurrent_uploads = 2; + config.media_max_concurrent_uploads_per_pubkey = 1; + + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.ensure_configured_community("relay.example") + .await + .expect("seed relay.example community for host-bound media tests"); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + async fn media_get_auth_router(require_media_get_auth: bool) -> axum::Router { + let state = test_state_with_media_get_auth(require_media_get_auth).await; + axum::Router::new() + .route( + "/media/{sha256_ext}", + axum::routing::get(get_blob).head(head_blob), + ) + .with_state(state) + } + + fn media_get_auth_header(keys: &Keys, tags: Vec) -> String { + let event = EventBuilder::new(Kind::from(24242), "Get media") + .tags(tags) + .sign_with_keys(keys) + .expect("sign get auth"); + format!( + "Nostr {}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + ) + } + + fn media_get_tags_for(host: &str, sha256: Option<&str>) -> Vec { + let now = Timestamp::now().as_secs(); + let expiration = (now + 300).to_string(); + let mut tags = vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["expiration", &expiration]).expect("expiration tag"), + Tag::parse(["server", host]).expect("server tag"), + ]; + if let Some(sha256) = sha256 { + tags.push(Tag::parse(["x", sha256]).expect("x tag")); + } + tags + } + + fn media_request(method: &str, auth: Option) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(format!("/media/{VALID_HASH}.jpg")) + .header(header::HOST, "relay.example"); + if let Some(auth) = auth { + builder = builder.header(header::AUTHORIZATION, auth); + } + builder.body(Body::empty()).expect("request") + } + + #[tokio::test] + async fn media_get_auth_flag_off_allows_unauthenticated_read_until_sidecar_gate() { + let response = media_get_auth_router(false) + .await + .oneshot(media_request("GET", None)) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn media_get_auth_flag_on_rejects_unauthenticated_get_and_head_before_sidecar_gate() { + for method in ["GET", "HEAD"] { + let response = media_get_auth_router(true) + .await + .oneshot(media_request(method, None)) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{method}"); + } + } + + #[tokio::test] + async fn media_get_auth_flag_on_valid_server_scoped_token_reaches_sidecar_gate() { + let keys = Keys::generate(); + let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); + let response = media_get_auth_router(true) + .await + .oneshot(media_request("GET", Some(auth))) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn media_get_auth_flag_on_rejects_upload_verb_wrong_server_and_wrong_x() { + let keys = Keys::generate(); + let now = Timestamp::now().as_secs(); + let expiration = (now + 300).to_string(); + let cases = [ + vec![ + Tag::parse(["t", "upload"]).expect("t tag"), + Tag::parse(["expiration", &expiration]).expect("expiration tag"), + Tag::parse(["server", "relay.example"]).expect("server tag"), + ], + vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["expiration", &expiration]).expect("expiration tag"), + Tag::parse(["server", "evil.example"]).expect("server tag"), + ], + vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["expiration", &expiration]).expect("expiration tag"), + Tag::parse(["x", &"f".repeat(64)]).expect("x tag"), + ], + ]; + + for tags in cases { + let auth = media_get_auth_header(&keys, tags); + let response = media_get_auth_router(true) + .await + .oneshot(media_request("GET", Some(auth))) + .await + .expect("response"); + + assert_ne!(response.status(), StatusCode::NOT_FOUND); + assert!( + response.status() == StatusCode::UNAUTHORIZED + || response.status() == StatusCode::FORBIDDEN, + "unexpected status {}", + response.status() + ); + } + } + + #[tokio::test] + async fn media_get_auth_flag_on_accepts_range_header_only_after_auth() { + let keys = Keys::generate(); + let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); + let mut request = media_request("GET", Some(auth)); + request + .headers_mut() + .insert(header::RANGE, "bytes=0-0".parse().expect("range header")); + + let response = media_get_auth_router(true) + .await + .oneshot(request) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn upload_rate_limiter_is_scoped_by_community() { + let state = test_state().await; + let pubkey = nostr::Keys::generate().public_key(); + let community_a = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xAAAA)); + let community_b = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xBBBB)); + + assert!(!upload_rate_limited(&state, community_a, &pubkey)); + assert!(upload_rate_limited(&state, community_a, &pubkey)); + assert!( + !upload_rate_limited(&state, community_b, &pubkey), + "A's exhausted upload budget must not rate-limit the same key in B" + ); + } + + #[tokio::test] + async fn upload_concurrency_limit_is_scoped_by_community() { + let state = test_state().await; + let pubkey = nostr::Keys::generate().public_key(); + let community_a = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xAAAA)); + let community_b = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xBBBB)); + + let permit_a = + acquire_upload_permit(&state, community_a, &pubkey).expect("first A upload allowed"); + assert!(matches!( + acquire_upload_permit(&state, community_a, &pubkey), + Err(MediaError::UploadConcurrencyLimitReached) + )); + let permit_b = acquire_upload_permit(&state, community_b, &pubkey) + .expect("A's in-flight upload must not block B"); + + drop(permit_b); + drop(permit_a); + } + #[test] fn test_validate_media_path_bare_hash() { assert!(validate_media_path(VALID_HASH).is_ok()); @@ -829,6 +1172,20 @@ mod tests { assert!(validate_media_path("").is_err()); } + #[test] + fn test_validate_media_path_rejects_upload_record_keys() { + // The `_uploads/` per-event records (and `_meta/` sidecars) must be + // unreachable through the serve path. Axum's single path segment + // can't even contain `/`, but assert the validator rejects these + // shapes outright so the property survives any routing change. + assert!(validate_media_path("_uploads").is_err()); + assert!(validate_media_path(&format!("_uploads/c/{VALID_HASH}/01J.json")).is_err()); + assert!(validate_media_path("_meta").is_err()); + assert!(validate_media_path(&format!("_meta/c/{VALID_HASH}.json")).is_err()); + // Suffix-style metadata keys are also non-servable (>3 segments / bad ext). + assert!(validate_media_path(&format!("{VALID_HASH}.png.metadata")).is_err()); + } + #[test] fn media_base_url_for_tenant_uses_tenant_host_and_http_scheme() { assert_eq!( diff --git a/crates/buzz-relay/src/api/mesh_demo.rs b/crates/buzz-relay/src/api/mesh_demo.rs new file mode 100644 index 0000000000..8649b97671 --- /dev/null +++ b/crates/buzz-relay/src/api/mesh_demo.rs @@ -0,0 +1,345 @@ +//! `POST /_mesh/demo/echo` — testbed-only join-side ingress for the mesh +//! reliable-stream smoke. +//! +//! This is the *client leg* of the `BUZZ_MESH_DEMO_ECHO` evidence run: the +//! owner-side echo consumer (see `mesh_boot::run_demo_echo`) validates and +//! echoes frames, but nothing in the product calls +//! [`ReliableStreamRouter::join`] yet — so cross-pod evidence needs a way to +//! drive a join from a chosen pod. This route is that way, and nothing more: +//! +//! - Gated on **both** `BUZZ_MESH_DEMO_ECHO=on` and mesh enabled; 404 +//! otherwise (the same strictness as the owner-side consumer — the route +//! does not exist unless the operator opted the deployment into the demo). +//! - `Owned` result: this pod acquired the fenced lease. No renewer is +//! spawned — this is a smoke, not a session — so the lease lives for its +//! Redis TTL (30s default). Drive the owner pod first, then the forwarding +//! pod within that window. +//! - `Forwarded` result: sends the payload to the owner over the mesh and +//! waits (bounded) for the echoed frame, proving owner-side +//! `recv_validated` (Redis fence included) and return-path delivery. +//! +//! Not a product flow. The real join-side consumer (goose/berd session +//! wiring) replaces this; the route stays demo-gated until it is deleted. + +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Json, Response}; +use buzz_core::CommunityId; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; + +use crate::state::AppState; +use crate::tunnel::directory::SessionDirectory; +use crate::tunnel::reliable::{ + ReliableFrame, ReliableJoin, ReliableStreamError, ReliableStreamRouter, +}; +use buzz_relay_mesh::RelayPeerTransport; + +/// How long the forwarded leg waits for the owner's echo before failing the +/// smoke. Generous relative to an intra-cluster RTT; small enough that a +/// wedged owner fails the run instead of hanging the probe. +const ECHO_TIMEOUT: Duration = Duration::from_secs(10); + +/// Request body for the demo echo probe. +#[derive(Debug, Deserialize)] +pub struct DemoEchoRequest { + /// Community/tenant scope for the fenced session. + pub community_id: Uuid, + /// Session to join. The first pod to post a given id becomes the owner. + pub session_id: Uuid, + /// Opaque payload for the echo round-trip (UTF-8 for readable evidence). + pub payload: String, +} + +/// `POST /_mesh/demo/echo` handler. 404 unless the deployment opted in. +pub async fn demo_echo( + State(state): State>, + Json(req): Json, +) -> Response { + // Same gate as the owner-side consumer: both flags, or the route does + // not exist. 404 (not 403) so a non-demo deployment is indistinguishable + // from one without the route. + let Some(handle) = state.mesh() else { + return StatusCode::NOT_FOUND.into_response(); + }; + if !state.config.mesh_demo_echo { + return StatusCode::NOT_FOUND.into_response(); + } + + let router = ReliableStreamRouter::new( + handle.directory.clone(), + Arc::clone(&handle.transport), + handle.local_runtime_id, + ); + run_demo_join(&router, &handle.directory, req).await +} + +/// Core of the probe, split from the handler so tests can drive it with a +/// directory + transport pair without standing up an `AppState`. +async fn run_demo_join( + router: &ReliableStreamRouter, + directory: &SessionDirectory, + req: DemoEchoRequest, +) -> Response +where + T: RelayPeerTransport + ?Sized, +{ + let community_id = CommunityId::from_uuid(req.community_id); + match router.join(community_id, req.session_id).await { + // This pod took (or already holds) the fenced lease. The lease is + // deliberately not renewed: it expires with its Redis TTL. + Ok(ReliableJoin::Owned { lease }) => Json(json!({ + "outcome": "owned", + "generation": lease.generation, + "owner_runtime_id": lease.owner_runtime_id.to_string(), + })) + .into_response(), + + // Another pod owns the session: send the payload over the mesh and + // wait for the owner-side echo consumer to bounce it back. + Ok(ReliableJoin::Forwarded { lease, mut stream }) => { + if let Err(e) = stream + .send_bytes(community_id, req.payload.as_bytes()) + .await + { + return echo_error(StatusCode::BAD_GATEWAY, "send failed", &e); + } + let echoed = tokio::time::timeout(ECHO_TIMEOUT, stream.recv_validated(directory)).await; + match echoed { + Ok(Ok(Some(ReliableFrame::Data(bytes)))) => Json(json!({ + "outcome": "forwarded", + "generation": lease.generation, + "owner_runtime_id": lease.owner_runtime_id.to_string(), + "echoed_payload": String::from_utf8_lossy(&bytes), + })) + .into_response(), + Ok(Ok(Some(ReliableFrame::Goodbye(reason)))) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": format!("owner sent goodbye: {reason:?}")})), + ) + .into_response(), + Ok(Ok(None)) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "stream closed before echo"})), + ) + .into_response(), + Ok(Err(e)) => echo_error(StatusCode::BAD_GATEWAY, "recv failed", &e), + Err(_) => ( + StatusCode::GATEWAY_TIMEOUT, + Json(json!({"error": "timed out waiting for echo"})), + ) + .into_response(), + } + } + + Err(e) => echo_error(StatusCode::BAD_GATEWAY, "join failed", &e), + } +} + +fn echo_error(status: StatusCode, what: &str, e: &ReliableStreamError) -> Response { + (status, Json(json!({"error": format!("{what}: {e}")}))).into_response() +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use axum::body::to_bytes; + use buzz_relay_mesh::endpoint::MeshEndpoint; + use buzz_relay_mesh::{ + InboundHandler, MeshDatagram, MeshError, MeshStream, MeshStreamFrame, RuntimeId, + StreamHello, + }; + use uuid::Uuid; + + use super::*; + use crate::tunnel::directory::SessionDirectory; + + fn pool() -> deadpool_redis::Pool { + let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()); + deadpool_redis::Config::from_url(url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("create redis pool") + } + + async fn redis_directory_if_available() -> Option { + let pool = pool(); + let mut conn = pool.get().await.ok()?; + redis::cmd("PING") + .query_async::(&mut *conn) + .await + .ok()?; + Some(SessionDirectory::with_lease_ttl( + pool, + Duration::from_secs(5), + )) + } + + struct NoopTransport; + + impl RelayPeerTransport for NoopTransport { + fn send_datagram(&self, _to: RuntimeId, _dgram: MeshDatagram) -> Result<(), MeshError> { + unreachable!("demo owned-arm test never sends datagrams") + } + + fn open_session_stream( + &self, + _to: RuntimeId, + _hello: StreamHello, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async { Err(MeshError::Transport("unexpected open".into())) }) + } + + fn set_inbound(&self, _handler: Box) {} + } + + struct DirectTransport { + peer: buzz_relay_mesh::peer::MeshPeer, + } + + impl RelayPeerTransport for DirectTransport { + fn send_datagram(&self, _to: RuntimeId, _dgram: MeshDatagram) -> Result<(), MeshError> { + unreachable!("demo forwarded-arm test never sends datagrams") + } + + fn open_session_stream( + &self, + _to: RuntimeId, + hello: StreamHello, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async move { + let mut stream = self.peer.open_bi().await?; + stream.send_frame(MeshStreamFrame::Hello(hello)).await?; + Ok(stream) + }) + } + + fn set_inbound(&self, _handler: Box) {} + } + + async fn body_json(resp: Response) -> serde_json::Value { + let bytes = to_bytes(resp.into_body(), 1024 * 1024).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + + /// First post for a session acquires the fenced lease and reports `owned`. + #[tokio::test] + async fn demo_join_owned_arm_reports_generation() { + let Some(directory) = redis_directory_if_available().await else { + return; + }; + let router = ReliableStreamRouter::new( + directory.clone(), + std::sync::Arc::new(NoopTransport), + RuntimeId([7; 32]), + ); + let resp = run_demo_join( + &router, + &directory, + DemoEchoRequest { + community_id: Uuid::new_v4(), + session_id: Uuid::new_v4(), + payload: "unused".into(), + }, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["outcome"], "owned"); + assert!(body["generation"].as_u64().is_some()); + } + + /// Second runtime forwards to the owner and round-trips the payload + /// through the owner-side echo consumer (`recv_validated` + `send_bytes`), + /// end to end over a real mesh stream pair. + #[tokio::test] + async fn demo_join_forwarded_arm_round_trips_echo() { + let Some(directory) = redis_directory_if_available().await else { + return; + }; + let community_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + + let bind = || "127.0.0.1:0".parse().unwrap(); + let local_endpoint = MeshEndpoint::bind(bind()).await.unwrap(); + let owner_endpoint = MeshEndpoint::bind(bind()).await.unwrap(); + let owner_runtime = owner_endpoint.runtime_id(); + let owner_addr = owner_endpoint.addr(); + + // Owner acquires the lease first (Mari's run order: podB first). + let owner_router = ReliableStreamRouter::new( + directory.clone(), + std::sync::Arc::new(NoopTransport), + owner_runtime, + ); + let owned = run_demo_join( + &owner_router, + &directory, + DemoEchoRequest { + community_id, + session_id, + payload: "unused".into(), + }, + ) + .await; + assert_eq!(owned.status(), StatusCode::OK); + assert_eq!(body_json(owned).await["outcome"], "owned"); + + // Owner side: accept the inbound mesh stream and run the real demo + // echo consumer against it. + let accept_endpoint = owner_endpoint.clone(); + let echo_directory = directory.clone(); + let owner_task = tokio::spawn(async move { + let peer = accept_endpoint.accept().await.unwrap().unwrap(); + let mut stream = peer.accept_bi().await.unwrap(); + let hello = match stream.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Hello(h) => h, + other => panic!("expected hello, got {other:?}"), + }; + let router = ReliableStreamRouter::new( + echo_directory.clone(), + std::sync::Arc::new(NoopTransport), + owner_runtime, + ); + let from = hello.sender; + let inbound = router.accept_inbound(from, hello, stream).await.unwrap(); + crate::mesh_boot::run_demo_echo( + echo_directory, + inbound, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .await; + }); + + // Forwarding side: join the same session through the demo core. + let local_peer = local_endpoint.connect(owner_addr).await.unwrap(); + let local_router = ReliableStreamRouter::new( + directory.clone(), + std::sync::Arc::new(DirectTransport { peer: local_peer }), + local_endpoint.runtime_id(), + ); + let resp = run_demo_join( + &local_router, + &directory, + DemoEchoRequest { + community_id, + session_id, + payload: "mesh echo evidence".into(), + }, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["outcome"], "forwarded"); + assert_eq!(body["echoed_payload"], "mesh echo evidence"); + owner_task.abort(); + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 10a88002ba..0880c8536a 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -3,8 +3,11 @@ pub mod bridge; pub mod events; pub mod git; +pub mod invites; pub mod media; +pub mod mesh_demo; pub mod nip05; +pub mod operator; // Re-export imeta helpers used by ingest pipeline. pub use crate::handlers::imeta::{validate_imeta_tags, verify_imeta_blobs}; diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs new file mode 100644 index 0000000000..5b69a43874 --- /dev/null +++ b/crates/buzz-relay/src/api/operator.rs @@ -0,0 +1,1252 @@ +//! Deployment-operator HTTP APIs. +//! +//! These routes are outside the Nostr event data plane. They still use NIP-98 +//! request signing and replay protection, but they do not run through event +//! ingest, relay membership, channel scoping, storage, or fan-out. + +use std::sync::Arc; + +use axum::{ + extract::{Query, RawQuery, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use buzz_core::{CommunityId, TenantContext}; + +use crate::handlers::community_provisioning::{ + normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest, +}; +use crate::state::AppState; + +use super::{api_error, bridge, internal_error}; + +/// Query parameters for `GET /operator/communities`. +#[derive(Debug, Deserialize)] +pub struct ListCommunitiesQuery { + owner_pubkey: String, +} + +/// Query parameters for `GET /operator/communities/availability`. +#[derive(Debug, Deserialize)] +pub struct CommunityAvailabilityQuery { + host: String, +} + +#[derive(Debug, Deserialize)] +struct TransferCommunityRequest { + community_id: String, + new_owner_pubkey: String, + expected_owner_pubkey: String, +} + +#[derive(Debug, Serialize)] +struct TransferCommunityResponse { + community_id: String, + new_owner_pubkey: String, + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + previous_owner: Option, +} + +const OPERATOR_REPLAY_SCOPE: &str = "operator-management"; + +/// Shared deployment-global operator auth prelude. The canonical management +/// origin and replay namespace are configuration, never tenant registry state +/// or an inbound proxy `Host` header. +async fn authorize_operator_request( + state: &Arc, + headers: &HeaderMap, + method: &str, + path: &str, + raw_query: Option<&str>, + body: Option<&[u8]>, +) -> Result)> { + let origin = state + .config + .relay_operator_api_origin + .as_deref() + .ok_or_else(|| internal_error("operator API origin is not configured"))?; + let path_with_query = match raw_query { + Some(q) if !q.is_empty() => format!("{path}?{q}"), + _ => path.to_string(), + }; + let url = format!("{origin}{path_with_query}"); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + headers, + method, + &url, + body, + true, // operator endpoints always require NIP-98; no X-Pubkey dev fallback + body.is_some(), + )?; + check_operator_replay(state, event_id_bytes).await?; + + let pubkey_hex = pubkey.to_hex(); + if !state + .config + .relay_operator_pubkeys + .iter() + .any(|pk| pk == &pubkey_hex) + { + return Err(api_error( + StatusCode::FORBIDDEN, + "actor not authorized: not a relay operator", + )); + } + + Ok(pubkey) +} + +async fn check_operator_replay( + state: &AppState, + event_id_bytes: [u8; 32], +) -> Result<(), (StatusCode, Json)> { + let event_id = nostr::EventId::from_byte_array(event_id_bytes); + match state + .nip98_replay + .try_mark_in_scope( + OPERATOR_REPLAY_SCOPE, + &event_id, + buzz_auth::DEFAULT_REPLAY_TTL_SECS, + ) + .await + { + Ok(true) => Ok(()), + Ok(false) => Err(api_error( + StatusCode::UNAUTHORIZED, + "NIP-98: replay detected", + )), + Err(error) => { + tracing::warn!( + scope = OPERATOR_REPLAY_SCOPE, + error = %error, + "operator NIP-98 replay guard failed; rejecting request fail-closed" + ); + Err(api_error( + StatusCode::UNAUTHORIZED, + "NIP-98: replay check unavailable", + )) + } + } +} + +/// Create a community host and atomically bootstrap its initial owner. +/// +/// `POST /operator/communities`, NIP-98 signed by a pubkey in +/// `RELAY_OPERATOR_PUBKEYS`, body: +/// +/// ```json +/// { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } +/// ``` +/// +/// The request is authenticated against `RELAY_OPERATOR_API_ORIGIN` and does +/// not bind the inbound host to a tenant. The operator allowlist is the +/// authority for this deployment-root control-plane surface. +pub async fn provision_community( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let pubkey = authorize_operator_request( + &state, + &headers, + "POST", + "/operator/communities", + None, + Some(&body), + ) + .await?; + + let request: ProvisionCommunityRequest = serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid provision-community JSON: {e}"), + ) + })?; + + match crate::handlers::community_provisioning::provision_community(&state, &pubkey, request) + .await + { + Ok(response) => Ok(Json(serde_json::to_value(response).map_err(|e| { + tracing::error!("failed to serialize provision-community response: {e}"); + internal_error("operator provision response serialization failed") + })?)), + Err(msg) if msg.starts_with("actor not authorized") => { + Err(api_error(StatusCode::FORBIDDEN, &msg)) + } + Err(msg) if msg == "community already exists" || msg.starts_with("limit_reached:") => { + Err(api_error(StatusCode::CONFLICT, &msg)) + } + Err(msg) + if msg.starts_with("failed to create community:") + || msg.starts_with("community provisioned but owner bootstrap failed:") => + { + tracing::error!(error = %msg, "operator community persistence failed"); + Err(internal_error("operator community persistence failed")) + } + Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)), + } +} + +/// Owner assertion supplied by the trusted operator client. +#[derive(Debug, Deserialize)] +pub struct ArchiveCommunityRequest { + host: String, + owner_pubkey: String, +} + +/// Idempotently archive a community owned by the asserted end-user identity. +pub async fn archive_community( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + const PATH: &str = "/operator/communities/archive"; + authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?; + let request: ArchiveCommunityRequest = serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid archive-community JSON: {e}"), + ) + })?; + let normalized_host = normalize_candidate_host(&request.host) + .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; + let deployment_host = buzz_core::tenant::relay_url_authority(&state.config.relay_url); + if normalized_host == deployment_host { + return Err(api_error( + StatusCode::CONFLICT, + "the deployment community cannot be archived", + )); + } + let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid owner_pubkey: expected 64-char hex pubkey", + ) + })?; + let record = state + .db + .archive_community_owned_by(&normalized_host, &owner, &deployment_host) + .await + .map_err(|e| internal_error(&format!("archive community: {e}")))? + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?; + let tenant = TenantContext::resolved(record.id, &record.host); + let closed = match state.disconnect_community_clusterwide(&tenant).await { + Ok(closed) => closed, + Err(error) => { + tracing::warn!(community = %record.id, host = %record.host, %error, "community archived but disconnect propagation is pending"); + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "community_id": record.id.to_string(), + "host": record.host, + "archived_at": record.archived_at, + "status": "archived", + "propagation": "pending", + "error": "connection propagation pending — retry this request", + })), + )); + } + }; + tracing::info!(community = %record.id, host = %record.host, local_connections_closed = closed, "community archived"); + Ok(Json(serde_json::json!({ + "community_id": record.id.to_string(), + "host": record.host, + "archived_at": record.archived_at, + "status": "archived", + }))) +} + +/// Idempotently restore an archived community owned by the asserted end-user identity. +pub async fn unarchive_community( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + const PATH: &str = "/operator/communities/unarchive"; + authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?; + let request: ArchiveCommunityRequest = serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid unarchive-community JSON: {e}"), + ) + })?; + let normalized_host = normalize_candidate_host(&request.host) + .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; + let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid owner_pubkey: expected 64-char hex pubkey", + ) + })?; + let record = state + .db + .unarchive_community_owned_by(&normalized_host, &owner) + .await + .map_err(|e| internal_error(&format!("unarchive community: {e}")))? + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?; + tracing::info!(community = %record.id, host = %record.host, "community unarchived"); + Ok(Json(serde_json::json!({ + "community_id": record.id.to_string(), + "host": record.host, + "archived_at": null, + "status": "active", + }))) +} + +/// List communities where a pubkey currently holds the `owner` role. +pub async fn list_owned_communities( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + authorize_operator_request( + &state, + &headers, + "GET", + "/operator/communities", + raw_query.as_deref(), + None, + ) + .await?; + + let owner_pubkey = validate_pubkey_hex(&query.owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid owner_pubkey: expected 64-char hex pubkey", + ) + })?; + + let rows = state + .db + .list_communities_owned_by(&owner_pubkey) + .await + .map_err(|e| internal_error(&format!("list owned communities: {e}")))?; + + Ok(Json(serde_json::json!({ + "owner_pubkey": owner_pubkey, + "communities": rows.into_iter().map(|row| serde_json::json!({ + "community_id": row.id.to_string(), + "host": row.host, + "created_at": row.created_at, + "archived_at": row.archived_at, + })).collect::>(), + }))) +} + +/// Transfer ownership of a community to a new owner pubkey. +/// +/// `POST /operator/communities/transfer`, NIP-98 signed by a pubkey in +/// `RELAY_OPERATOR_PUBKEYS`, body: +/// +/// ```json +/// { "community_id": "", "new_owner_pubkey": "" } +/// ``` +/// +/// The previous owner is demoted to `member` (not `admin`). The transfer is +/// instant and atomic at the database layer. Publication of the updated +/// NIP-43 membership list is best-effort, matching the provision path. +pub async fn transfer_community( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let _pubkey = authorize_operator_request( + &state, + &headers, + "POST", + "/operator/communities/transfer", + None, + Some(&body), + ) + .await?; + + let request: TransferCommunityRequest = serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid transfer-community JSON: {e}"), + ) + })?; + + let community_uuid = Uuid::parse_str(&request.community_id).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid community_id: {e}"), + ) + })?; + + let new_owner_pubkey = validate_pubkey_hex(&request.new_owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid new_owner_pubkey: expected 64-char hex pubkey", + ) + })?; + + let expected_owner_pubkey = + validate_pubkey_hex(&request.expected_owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid expected_owner_pubkey: expected 64-char hex pubkey", + ) + })?; + + let community = CommunityId::from_uuid(community_uuid); + + let result = state + .db + .transfer_ownership(community, &new_owner_pubkey, &expected_owner_pubkey) + .await + .map_err(|e| internal_error(&format!("transfer ownership: {e}")))?; + + let (status, previous_owner) = match result { + buzz_db::relay_members::TransferResult::Transferred { previous_owner } => { + ("transferred", previous_owner) + } + buzz_db::relay_members::TransferResult::AlreadyOwner => ("already_owner", None), + buzz_db::relay_members::TransferResult::NoOwner => { + return Err(api_error( + StatusCode::NOT_FOUND, + "community has no owner to transfer from", + )); + } + buzz_db::relay_members::TransferResult::OwnerConflict => { + return Err(api_error( + StatusCode::CONFLICT, + "owner_conflict: the current owner no longer matches expected_owner_pubkey", + )); + } + buzz_db::relay_members::TransferResult::LimitReached => { + return Err(api_error( + StatusCode::CONFLICT, + "limit_reached: transferee already owns the maximum number of communities", + )); + } + }; + + // Best-effort NIP-43 membership snapshot publication — same pattern as + // provision_community. The DB mutation is already committed; a publication + // failure must not turn a success into an HTTP error. + if state.config.require_relay_membership { + if let Some(host) = state + .db + .lookup_community_host(community) + .await + .map_err(|e| internal_error(&format!("lookup community host: {e}")))? + { + let tenant = TenantContext::resolved(community, host); + if let Err(error) = + crate::handlers::side_effects::publish_nip43_membership_list(&tenant, &state).await + { + tracing::warn!( + community = %community, + error = %error, + "ownership transferred but NIP-43 membership snapshot publication failed" + ); + } + } + } + + let response = TransferCommunityResponse { + community_id: request.community_id, + new_owner_pubkey, + status, + previous_owner, + }; + + Ok(Json(serde_json::to_value(response).map_err(|e| { + tracing::error!("failed to serialize transfer-community response: {e}"); + internal_error("operator transfer response serialization failed") + })?)) +} +/// Check whether a community host is available, returning the relay-canonical +/// normalized authority used by create. +pub async fn community_availability( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + authorize_operator_request( + &state, + &headers, + "GET", + "/operator/communities/availability", + raw_query.as_deref(), + None, + ) + .await?; + + let normalized_host = normalize_candidate_host(&query.host) + .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; + let existing = state + .db + .lookup_community_by_host_for_management(&normalized_host) + .await + .map_err(|e| internal_error(&format!("check community availability: {e}")))?; + + Ok(Json(serde_json::json!({ + "host": query.host, + "normalized_host": normalized_host, + "available": existing.is_none(), + "community_id": existing.map(|record| record.id.to_string()), + }))) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::{ + body::{to_bytes, Body}, + http::{header, Request, StatusCode}, + }; + use base64::Engine; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use serde_json::Value; + use sha2::{Digest, Sha256}; + use tower::ServiceExt; + use uuid::Uuid; + + use buzz_core::{kind::KIND_NIP43_MEMBERSHIP_LIST, CommunityId}; + use buzz_db::event::EventQuery; + + use crate::router::build_router; + use crate::state::AppState; + + struct AlwaysFreshReplayGuard; + + impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async { Ok(true) }) + } + } + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + const INGRESS_HOST: &str = "operator-ingress.example"; + + fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { + let mut tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", method]).expect("method tag"), + ]; + if let Some(body) = body { + let hash: [u8; 32] = Sha256::digest(body).into(); + let hash_hex = hex::encode(hash); + tags.push(Tag::parse(["payload", hash_hex.as_str()]).expect("payload tag")); + } + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); + let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes()); + format!("Nostr {encoded}") + } + + fn nip98_auth_header_without_payload(keys: &Keys, url: &str, method: &str) -> String { + let tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", method]).expect("method tag"), + ]; + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); + let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes()); + format!("Nostr {encoded}") + } + + async fn operator_test_state(operator_keys: &[Keys]) -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = TEST_DB_URL.to_string(); + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_url = "wss://tenant.example".to_string(); + config.relay_operator_api_origin = Some(format!("http://{INGRESS_HOST}")); + config.relay_operator_pubkeys = operator_keys + .iter() + .map(|keys| keys.public_key().to_hex()) + .collect(); + config.require_relay_membership = true; + + let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + async fn read_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("read response body"); + serde_json::from_slice(&bytes).expect("response JSON") + } + + async fn signed_operator_request( + state: Arc, + keys: &Keys, + method: &str, + path: &str, + body: Option, + ) -> axum::response::Response { + let url = format!("http://{INGRESS_HOST}{path}"); + let auth = nip98_auth_header(keys, &url, method, body.as_deref().map(str::as_bytes)); + let mut request = Request::builder() + .method(method) + .uri(path) + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth); + if body.is_some() { + request = request.header(header::CONTENT_TYPE, "application/json"); + } + build_router(state) + .oneshot( + request + .body(body.map_or_else(Body::empty, Body::from)) + .expect("request"), + ) + .await + .expect("response") + } + + async fn provision_community( + state: Arc, + operator: &Keys, + host: &str, + owner: &Keys, + ) -> axum::response::Response { + let body = serde_json::json!({ + "host": host, + "initial_owner_pubkey": owner.public_key().to_hex(), + "create_only": true, + }) + .to_string(); + signed_operator_request(state, operator, "POST", "/operator/communities", Some(body)).await + } + + fn is_member_tag(tag: &Tag, pubkey: &str, role: &str) -> bool { + let values = tag.as_slice(); + values.first().is_some_and(|value| value == "member") + && values.get(1).is_some_and(|value| value == pubkey) + && values.get(2).is_some_and(|value| value == role) + } + + async fn assert_snapshot_roles( + state: &AppState, + community: CommunityId, + expected: &[(&str, &str)], + ) { + let snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![KIND_NIP43_MEMBERSHIP_LIST as i32]), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(community) + }) + .await + .expect("query membership snapshot") + .into_iter() + .next() + .expect("membership snapshot published"); + for &(pubkey, role) in expected { + assert!( + snapshot + .event + .tags + .iter() + .any(|tag| is_member_tag(tag, pubkey, role)), + "missing {role} snapshot tag for {pubkey}" + ); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn non_allowlisted_operator_key_gets_403() { + let operator = Keys::generate(); + let outsider = Keys::generate(); + let Some(state) = operator_test_state(&[operator]).await else { + return; + }; + let body = format!( + r#"{{"host":"community-{}.example"}}"#, + Uuid::new_v4().simple() + ); + let url = format!("http://{INGRESS_HOST}/operator/communities"); + let auth = nip98_auth_header(&outsider, &url, "POST", Some(body.as_bytes())); + + let response = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn post_operator_body_requires_payload_tag() { + let operator = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let body = format!( + r#"{{"host":"community-{}.example"}}"#, + Uuid::new_v4().simple() + ); + let url = format!("http://{INGRESS_HOST}/operator/communities"); + let auth = nip98_auth_header_without_payload(&operator, &url, "POST"); + + let response = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let json = read_json(response).await; + assert!( + json.get("error") + .and_then(Value::as_str) + .unwrap_or_default() + .contains("missing payload tag"), + "unexpected response: {json:?}" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unmapped_management_host_can_check_availability() { + let operator = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + let query = format!("host={host}"); + let url = format!("http://{INGRESS_HOST}/operator/communities/availability?{query}"); + let auth = nip98_auth_header(&operator, &url, "GET", None); + + let response = build_router(state) + .oneshot( + Request::builder() + .uri(format!("/operator/communities/availability?{query}")) + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!(json.get("available").and_then(Value::as_bool), Some(true)); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unmapped_management_host_can_list_owned_communities() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let owner_hex = owner.public_key().to_hex(); + let query = format!("owner_pubkey={owner_hex}"); + let url = format!("http://{INGRESS_HOST}/operator/communities?{query}"); + let auth = nip98_auth_header(&operator, &url, "GET", None); + + let response = build_router(state) + .oneshot( + Request::builder() + .uri(format!("/operator/communities?{query}")) + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!( + json.get("owner_pubkey").and_then(Value::as_str), + Some(owner_hex.as_str()) + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unarchive_restores_admission_and_is_idempotent_without_changing_ownership() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let outsider = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + let owner_hex = owner.public_key().to_hex(); + let archived = state + .db + .archive_community_owned_by(&host, &owner_hex, "protected.example") + .await + .expect("archive community") + .expect("owned community"); + assert!(state + .db + .lookup_community_by_host(&host) + .await + .expect("archived admission lookup") + .is_none()); + + let request = |host: &str, owner_pubkey: String| { + serde_json::json!({ + "host": host, + "owner_pubkey": owner_pubkey, + }) + .to_string() + }; + let wrong_owner = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/unarchive", + Some(request(&host, outsider.public_key().to_hex())), + ) + .await; + assert_eq!(wrong_owner.status(), StatusCode::NOT_FOUND); + assert_eq!(read_json(wrong_owner).await["error"], "community not found"); + let unknown = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/unarchive", + Some(request("missing.example", owner_hex.clone())), + ) + .await; + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + assert_eq!(read_json(unknown).await["error"], "community not found"); + + for attempt in 0..2 { + let response = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/unarchive", + Some(request(&host, owner_hex.clone())), + ) + .await; + assert_eq!(response.status(), StatusCode::OK, "attempt {attempt}"); + let json = read_json(response).await; + assert_eq!(json["community_id"], archived.id.to_string()); + assert_eq!(json["host"], host); + assert!(json["archived_at"].is_null()); + assert_eq!(json["status"], "active"); + } + + let active = state + .db + .lookup_community_by_host(&host) + .await + .expect("restored admission lookup") + .expect("active community"); + assert_eq!(active.id, archived.id); + let owner_member = state + .db + .get_relay_member(active.id, &owner_hex) + .await + .expect("owner lookup") + .expect("owner remains"); + assert_eq!(owner_member.role, "owner"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn archive_publish_failure_is_retryable_and_preserves_timestamp() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + let owner_hex = owner.public_key().to_hex(); + let create_body = serde_json::json!({ + "host": host, + "initial_owner_pubkey": owner_hex, + "create_only": true, + }) + .to_string(); + let create_url = format!("http://{INGRESS_HOST}/operator/communities"); + let create_auth = + nip98_auth_header(&operator, &create_url, "POST", Some(create_body.as_bytes())); + let create_response = build_router(Arc::clone(&state)) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, create_auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(create_body)) + .expect("create request"), + ) + .await + .expect("create response"); + assert_eq!(create_response.status(), StatusCode::OK); + + let archive_body = serde_json::json!({ + "host": host, + "owner_pubkey": owner.public_key().to_hex(), + }) + .to_string(); + let archive_url = format!("http://{INGRESS_HOST}/operator/communities/archive"); + let archive_once = |state: Arc| { + let auth = nip98_auth_header( + &operator, + &archive_url, + "POST", + Some(archive_body.as_bytes()), + ); + let body = archive_body.clone(); + async move { + build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities/archive") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("archive request"), + ) + .await + .expect("archive response") + } + }; + + let first = archive_once(Arc::clone(&state)).await; + assert_eq!(first.status(), StatusCode::SERVICE_UNAVAILABLE); + let first_json = read_json(first).await; + assert_eq!(first_json["status"], "archived"); + assert_eq!(first_json["propagation"], "pending"); + let first_archived_at = first_json["archived_at"].clone(); + assert!(!first_archived_at.is_null()); + assert!(state + .db + .lookup_community_by_host(&host) + .await + .expect("active lookup") + .is_none()); + + assert_eq!( + state + .community_disconnect_publish_attempts + .load(std::sync::atomic::Ordering::Relaxed), + 1 + ); + let second = archive_once(Arc::clone(&state)).await; + assert_eq!(second.status(), StatusCode::SERVICE_UNAVAILABLE); + let second_json = read_json(second).await; + assert_eq!(second_json["archived_at"], first_archived_at); + assert_eq!( + state + .community_disconnect_publish_attempts + .load(std::sync::atomic::Ordering::Relaxed), + 2, + "idempotent archive retry must republish the disconnect" + ); + + let owned = state + .db + .list_communities_owned_by(&owner.public_key().to_hex()) + .await + .expect("owned communities"); + let row = owned + .iter() + .find(|row| row.host == host) + .expect("archived row"); + assert_eq!( + serde_json::to_value(row.archived_at).expect("timestamp JSON"), + first_archived_at + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn happy_path_create_returns_created_and_bootstraps_owner() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + let response = provision_community(state.clone(), &operator, &host, &owner).await; + + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!(json.get("status").and_then(Value::as_str), Some("created")); + assert_eq!( + json.get("host").and_then(Value::as_str), + Some(host.as_str()) + ); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .expect("community exists"); + let owner_hex = owner.public_key().to_hex(); + let member = state + .db + .get_relay_member(community.id, &owner_hex) + .await + .expect("lookup owner role") + .expect("owner member exists"); + assert_eq!(member.role, "owner"); + + assert_snapshot_roles(&state, community.id, &[(&owner_hex, "owner")]).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn fresh_host_at_owner_limit_returns_limit_reached_conflict() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + + for _ in 0..buzz_db::relay_members::MAX_COMMUNITIES_PER_OWNER { + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(state.clone(), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + } + + let host = format!("community-{}.example", Uuid::new_v4().simple()); + let response = provision_community(state.clone(), &operator, &host, &owner).await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let json = read_json(response).await; + assert!(json["error"] + .as_str() + .is_some_and(|error| error.starts_with("limit_reached:"))); + assert!(state + .db + .lookup_community_by_host(&host) + .await + .expect("look up rejected fresh host") + .is_none()); + } + + /// Happy path: POST /operator/communities/transfer swaps ownership, demotes + /// the old owner to `member`, and publishes a NIP-43 snapshot reflecting the + /// new roles. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn happy_path_transfer_swaps_owner_and_demotes_old_to_member() { + let operator = Keys::generate(); + let initial_owner = Keys::generate(); + let new_owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + + let host = format!("community-{}.example", Uuid::new_v4().simple()); + let create_response = + provision_community(state.clone(), &operator, &host, &initial_owner).await; + assert_eq!(create_response.status(), StatusCode::OK); + + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .expect("community exists"); + let community_id = community.id.to_string(); + let initial_owner_hex = initial_owner.public_key().to_hex(); + let new_owner_hex = new_owner.public_key().to_hex(); + + let transfer_body = serde_json::json!({ + "community_id": community_id, + "new_owner_pubkey": new_owner_hex, + "expected_owner_pubkey": initial_owner_hex, + }) + .to_string(); + let response = signed_operator_request( + state.clone(), + &operator, + "POST", + "/operator/communities/transfer", + Some(transfer_body), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!( + json.get("status").and_then(Value::as_str), + Some("transferred") + ); + assert_eq!( + json.get("new_owner_pubkey").and_then(Value::as_str), + Some(new_owner_hex.as_str()) + ); + assert_eq!( + json.get("previous_owner").and_then(Value::as_str), + Some(initial_owner_hex.as_str()) + ); + + // New owner is owner. + assert_eq!( + state + .db + .get_relay_member(community.id, &new_owner_hex) + .await + .expect("get new owner") + .expect("new owner exists") + .role, + "owner" + ); + // Old owner is member (not admin). + assert_eq!( + state + .db + .get_relay_member(community.id, &initial_owner_hex) + .await + .expect("get old owner") + .expect("old owner exists") + .role, + "member" + ); + + assert_snapshot_roles( + &state, + community.id, + &[(&new_owner_hex, "owner"), (&initial_owner_hex, "member")], + ) + .await; + } + + /// Transfer with an invalid community_id returns 400. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_with_invalid_community_id_returns_400() { + let operator = Keys::generate(); + let new_owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let body = serde_json::json!({ + "community_id": "not-a-uuid", + "new_owner_pubkey": new_owner.public_key().to_hex(), + "expected_owner_pubkey": new_owner.public_key().to_hex(), + }) + .to_string(); + let response = signed_operator_request( + state, + &operator, + "POST", + "/operator/communities/transfer", + Some(body), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + /// Transfer with an invalid new_owner_pubkey returns 400. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_with_invalid_pubkey_returns_400() { + let operator = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let body = serde_json::json!({ + "community_id": Uuid::new_v4().to_string(), + "new_owner_pubkey": "not-a-pubkey", + "expected_owner_pubkey": "not-a-pubkey", + }) + .to_string(); + let response = signed_operator_request( + state, + &operator, + "POST", + "/operator/communities/transfer", + Some(body), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 9e930ccc40..0020df3b8e 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -27,7 +27,7 @@ use nostr::{EventBuilder, Kind, Tag}; use serde::Deserialize; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use uuid::Uuid; use buzz_auth::generate_challenge; @@ -38,7 +38,7 @@ use buzz_core::StoredEvent; use buzz_pubsub::EventTopic; use crate::audio::room::PeerCtrl; -use crate::state::AppState; +use crate::state::{run_registered_community_connection, AppState}; /// Maximum binary frame size: 4 KB is generous for a single Opus packet. const MAX_AUDIO_FRAME_BYTES: usize = 4096; @@ -112,6 +112,29 @@ async fn handle_audio_connection( state: Arc, tenant: TenantContext, channel_id: Uuid, +) { + let cancel = CancellationToken::new(); + let community_id = tenant.community(); + let registry = Arc::clone(&state.community_connections); + let check_state = Arc::clone(&state); + let run_state = Arc::clone(&state); + run_registered_community_connection( + ®istry, + Uuid::new_v4(), + community_id, + cancel.clone(), + move || async move { check_state.db.is_community_active(community_id).await }, + move || handle_active_audio_connection(socket, run_state, tenant, channel_id, cancel), + ) + .await; +} + +async fn handle_active_audio_connection( + socket: WebSocket, + state: Arc, + tenant: TenantContext, + channel_id: Uuid, + cancel: CancellationToken, ) { let (mut ws_send, mut ws_recv) = socket.split(); @@ -126,23 +149,26 @@ async fn handle_audio_connection( return; } - let auth_result = tokio::time::timeout(AUTH_TIMEOUT, async { - while let Some(Ok(msg)) = ws_recv.next().await { - if let WsMessage::Text(text) = msg { - if text.len() > MAX_TEXT_FRAME_BYTES { - warn!(channel_id = %channel_id, "auth text frame too large — dropping"); - continue; - } - if let Ok(auth) = serde_json::from_str::(&text) { - if auth.msg_type == "auth" { - return Some(auth); + let auth_result = tokio::select! { + biased; + _ = cancel.cancelled() => return, + result = tokio::time::timeout(AUTH_TIMEOUT, async { + while let Some(Ok(msg)) = ws_recv.next().await { + if let WsMessage::Text(text) = msg { + if text.len() > MAX_TEXT_FRAME_BYTES { + warn!(channel_id = %channel_id, "auth text frame too large — dropping"); + continue; + } + if let Ok(auth) = serde_json::from_str::(&text) { + if auth.msg_type == "auth" { + return Some(auth); + } } } } - } - None - }) - .await; + None + }) => result, + }; let auth_msg = match auth_result { Ok(Some(a)) => a, @@ -224,33 +250,95 @@ async fn handle_audio_connection( } }; - // Huddle audio guardrail (plan §5b). Audio frames are relayed only within - // a single pod; under horizontal scaling (any-pod-any-connection) two peers - // in one huddle can land on different pods and never hear each other. A - // multi-pod deployment sets `huddle_audio_available = false`, and we surface - // a clear, client-handleable "unavailable" signal here — BEFORE joining a - // room — rather than shipping a silent split-room. Single-pod deployments - // leave the flag at its `true` default and keep today's behavior. The fix - // is an out-of-relay media/SFU service (Tyler's long-term target), not - // sticky-routing huddles into this rewrite. - if !state.config.huddle_audio_available { - debug!( - channel_id = %channel_id, - pubkey = %pubkey_hex, - "huddle audio unavailable under horizontal scaling — rejecting join" - ); - let _ = ws_send - .send(WsMessage::Text( - serde_json::json!({ - "type": "error", - "code": "huddle_audio_unavailable", - "message": "huddle audio unavailable in this deployment" - }) - .to_string() - .into(), - )) - .await; - return; + // Huddle cross-pod routing (mesh) OR single-pod guardrail. + // + // When the mesh is live (`state.mesh()` is `Some`), a huddle can span pods: + // Redis arbitrates ownership and this pod either owns the room locally or + // forwards the client to the owner over a `HuddleControl` stream. When the + // mesh is off, we keep today's behavior exactly — including the + // `huddle_audio_available=false` rejection under a non-mesh horizontal + // deployment (two peers on different pods would never hear each other). + // + // `remote_owner` is `Some` only on the non-owner path; it carries the + // registration to the owner and, once the client is admitted locally, is + // opened so its media forwards to the owner instead of fanning out locally. + let mut pending_remote: Option = None; + // The freshly-acquired owner lease, if this connection won the CAS. Held + // until `add_peer` succeeds, then installed in the owner registry so the + // renewer's lifetime matches the room's, not this connection's failure + // paths (archived channel, version reject, room full) which return early. + let mut acquired_lease: Option = None; + match state.mesh() { + Some(mesh) => { + if mesh.owners.is_draining() { + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({ + "type": "error", + "code": "huddle_relay_draining", + "message": "relay is draining; reconnect" + }) + .to_string() + .into(), + )) + .await; + return; + } + match crate::audio::join::resolve_join_owner_ready( + &mesh.directory, + tenant.community(), + channel_id, + mesh.local_runtime_id, + &mesh.owners, + ) + .await + { + Ok(resolved) => { + acquired_lease = resolved.acquired; + pending_remote = Some(resolved.outcome); + } + Err(e) => { + warn!( + channel_id = %channel_id, + pubkey = %pubkey_hex, + "huddle join rejected by fence: {e}" + ); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({ + "type": "error", + "code": "join_rejected", + "message": "huddle join rejected" + }) + .to_string() + .into(), + )) + .await; + return; + } + } + } + None => { + if !state.config.huddle_audio_available { + debug!( + channel_id = %channel_id, + pubkey = %pubkey_hex, + "huddle audio unavailable under horizontal scaling — rejecting join" + ); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({ + "type": "error", + "code": "huddle_audio_unavailable", + "message": "huddle audio unavailable in this deployment" + }) + .to_string() + .into(), + )) + .await; + return; + } + } } let room = state.audio_rooms.get_or_create(channel_id); @@ -310,53 +398,107 @@ async fn handle_audio_connection( return; } - let (peer_id, peer_index, audio_rx, peer_ctrl_rx) = match room - .add_peer(pubkey_hex.clone(), requested_version) + // Remote registration happens before ingress admission. The owner-assigned + // index is therefore the only index this client ever has; no frame or + // `joined` message can escape with an ingress-local placeholder. + let mut remote_session: Option = None; + let mut remote_stream: Option = None; + let mut remote_fence: Option> = None; + if let (Some(mesh), Some(crate::audio::join::JoinOutcome::RemoteOwner { .. })) = + (state.mesh(), pending_remote) { - Ok(v) => v, - Err(crate::audio::room::AdmissionError::Full) => { - warn!(channel_id = %channel_id, "audio room full (255 peers exhausted)"); - let _ = ws_send + let outcome = pending_remote.expect("RemoteOwner matched above"); + let fenced = outcome.fenced_header(channel_id, mesh.local_runtime_id); + let crate::audio::join::JoinOutcome::RemoteOwner { + owner_runtime_id, .. + } = outcome + else { + unreachable!("matched RemoteOwner above"); + }; + match crate::audio::join::dial_remote_owner( + Arc::clone(&mesh.transport), + mesh.local_runtime_id, + owner_runtime_id, + fenced, + tenant.community(), + pubkey_hex.clone(), + requested_version, + ) + .await + { + Ok((session, stream)) => { + remote_session = Some(session); + remote_stream = Some(stream); + remote_fence = Some(Arc::clone(&mesh.audio_fence)); + } + Err(crate::audio::join::DialError::Rejected(reason)) => { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "huddle owner rejected registration: {reason:?}"); + let _ = ws_send .send(WsMessage::Text( - serde_json::json!({"type":"error","code":"room_full","message":"peer index space exhausted"}) - .to_string().into(), + remote_rejection_ws_error(&reason).to_string().into(), )) .await; - return; - } - Err(crate::audio::room::AdmissionError::Ended) => { - debug!(channel_id = %channel_id, "room ended before admission"); - let _ = ws_send - .send(WsMessage::Text( - serde_json::json!({"type":"error","code":"room_ended","message":"huddle has ended"}) - .to_string().into(), - )) - .await; - return; - } - Err(crate::audio::room::AdmissionError::VersionMismatch { pinned, requested }) => { - info!( - channel_id = %channel_id, - pubkey = %pubkey_hex, - pinned, - requested, - "audio: protocol version mismatch — upgrade required" - ); - let _ = ws_send + state.audio_rooms.cleanup_if_empty(channel_id); + return; + } + Err(crate::audio::join::DialError::Mesh(e)) => { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "huddle owner registration failed: {e}"); + let _ = ws_send .send(WsMessage::Text( serde_json::json!({ - "type": "error", - "code": "upgrade_required", - "message": format!( - "this huddle is using audio protocol v{pinned}; your client requested v{requested}" - ), - "pinned_version": pinned, - "requested_version": requested, + "type": "error", "code": "huddle_owner_unreachable", + "message": "could not reach the huddle owner" }) .to_string() .into(), )) .await; + state.audio_rooms.cleanup_if_empty(channel_id); + return; + } + } + } + + let admission = if let Some(session) = remote_session.as_ref() { + room.add_peer_at_index(pubkey_hex.clone(), requested_version, session.peer_index()) + .map(|(id, audio, ctrl)| (id, session.peer_index(), audio, ctrl)) + } else { + room.add_peer(pubkey_hex.clone(), requested_version) + }; + let (peer_id, peer_index, audio_rx, peer_ctrl_rx) = match admission { + Ok(v) => v, + Err(crate::audio::room::AdmissionError::Full) => { + warn!(channel_id = %channel_id, "audio room full (255 peers exhausted)"); + let _ = ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_full","message":"peer index space exhausted"}).to_string().into())).await; + if let (Some(session), Some(stream)) = (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_clean_close(stream, session.fenced(), session.pubkey()) + .await; + } + return; + } + Err(crate::audio::room::AdmissionError::Ended) => { + debug!(channel_id = %channel_id, "room ended before admission"); + let _ = ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_ended","message":"huddle has ended"}).to_string().into())).await; + if let (Some(session), Some(stream)) = (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_clean_close(stream, session.fenced(), session.pubkey()) + .await; + } + return; + } + Err(crate::audio::room::AdmissionError::VersionMismatch { pinned, requested }) => { + info!(channel_id = %channel_id, pubkey = %pubkey_hex, pinned, requested, "audio: protocol version mismatch — upgrade required"); + let _ = ws_send.send(WsMessage::Text(serde_json::json!({ + "type": "error", "code": "upgrade_required", + "message": format!("this huddle is using audio protocol v{pinned}; your client requested v{requested}"), + "pinned_version": pinned, "requested_version": requested, + }).to_string().into())).await; + if let (Some(session), Some(stream)) = (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_clean_close(stream, session.fenced(), session.pubkey()) + .await; + } return; } }; @@ -368,11 +510,67 @@ async fn handle_audio_connection( "audio peer joined" ); - let peers_snapshot: Vec = room - .peer_pubkeys() - .into_iter() - .map(|(pk, idx)| serde_json::json!({"pubkey": pk, "peer_index": idx})) - .collect(); + // Owner path: install (or reuse) this room's single lease renewer now that + // a peer is admitted, and capture its owner-loss signal. The connection + // that won the CAS holds `acquired_lease`; it installs the renewer. A + // steady-state owner (an earlier joiner installed it) reuses the room's + // existing signal. `owner_lost` drives this connection's own teardown + // below; `owner_generation` fences the release on room-empty so a stale + // teardown cannot release a newer epoch a re-acquire installed. + // + // The reuse arm's live entry is guaranteed by `resolve_join_owner_ready`: + // it re-resolves until the CAS winner has installed (reuse) or a fresh CAS + // wins (acquire), never returning a `LocalOwner` snapshot with a missing + // registry entry. So a local owner peer here always gets a real `lost` + // watcher — the ownerless split-brain (an owner peer fanning stale media + // with no way to observe lease loss, since local WS peers have no per-frame + // fence) cannot occur. A `None` on the reuse arm is therefore an invariant + // violation, not a benign race; log it loudly rather than proceed silently. + let mut owner_lost: Option = None; + let mut owner_draining: Option = None; + let mut owner_generation: Option = None; + if let Some(mesh) = state.mesh() { + match (pending_remote, acquired_lease.take()) { + (Some(crate::audio::join::JoinOutcome::LocalOwner { generation }), Some(lease)) => { + let signals = + mesh.owners + .attach_signals(channel_id, Arc::new(mesh.directory.clone()), lease); + owner_lost = Some(signals.lost); + owner_draining = Some(signals.draining); + owner_generation = Some(generation); + } + (Some(crate::audio::join::JoinOutcome::LocalOwner { generation }), None) => { + owner_lost = mesh.owners.lost_for(channel_id); + owner_draining = mesh.owners.drain_for(channel_id); + owner_generation = Some(generation); + if owner_lost.is_none() { + error!( + channel_id = %channel_id, + "huddle owner-ready invariant violated: LocalOwner reuse with no live \ + registry entry after resolve_join_owner_ready — owner peer has no \ + lease-loss watcher" + ); + } + } + _ => {} + } + } + + // Remote registration and owner-assigned ingress admission completed above. + + let peers_snapshot: Vec = if let Some(session) = remote_session.as_ref() { + session + .roster() + .peers + .iter() + .map(|peer| serde_json::json!({"pubkey": peer.pubkey, "peer_index": peer.peer_index})) + .collect() + } else { + room.peer_pubkeys() + .into_iter() + .map(|(pk, idx)| serde_json::json!({"pubkey": pk, "peer_index": idx})) + .collect() + }; let joined_msg = serde_json::json!({ "type": "joined", @@ -382,7 +580,19 @@ async fn handle_audio_connection( }) .to_string(); - room.broadcast_control(joined_msg); + if remote_session.is_some() { + if ws_send + .send(WsMessage::Text(joined_msg.into())) + .await + .is_err() + { + room.remove_peer(peer_id); + state.audio_rooms.cleanup_if_empty(channel_id); + return; + } + } else { + room.broadcast_control(joined_msg); + } // ── Step 6: emit kind:48101 (PARTICIPANT_JOINED) ────────────────────────── emit_participant_event( @@ -395,7 +605,6 @@ async fn handle_audio_connection( ) .await; - let cancel = CancellationToken::new(); let missed_pongs = Arc::new(AtomicU8::new(0)); // Dual-channel pattern (matches connection.rs): data channel for audio, @@ -419,6 +628,100 @@ async fn handle_audio_connection( fwd_cancel, )); + // Non-owner path: own the owner's `HuddleControl` stream in a reader task. + // It races the owner's teardown signal against our own cancellation: + // * owner speaks first (`Goodbye` / stream close) → tear the client down + // and close its WS so it rejoins (against a fresh owner/generation), + // and forget the local generation floor so the rejoin isn't fenced by + // the dead session. Redis remains the ownership arbiter; forgetting the + // floor only clears local stale-frame suppression. + // * we cancel first (client left / heartbeat death) → send the clean + // `UnregisterPeer` + `Goodbye(SessionEnded)` so the owner drops us. + let reader_task = remote_stream.map(|mut stream| { + let reader_cancel = cancel.clone(); + let fence = remote_fence.expect("remote_fence set whenever remote_stream is"); + let fenced = remote_session + .as_ref() + .expect("remote_session set whenever remote_stream is") + .fenced(); + let pubkey = remote_session + .as_ref() + .expect("remote_session set whenever remote_stream is") + .pubkey() + .to_string(); + let roster_revision = remote_session + .as_ref() + .expect("remote_session set whenever remote_stream is") + .roster() + .revision; + let roster_ctrl_tx = ctrl_tx.clone(); + tokio::spawn(async move { + tokio::select! { + cause = crate::audio::join::read_owner_control( + &mut stream, + fenced, + roster_revision, + &roster_ctrl_tx, + ) => { + teardown_remote_huddle(cause, channel_id, &reader_cancel, &fence); + } + _ = reader_cancel.cancelled() => { + crate::audio::join::send_clean_close(&mut stream, fenced, &pubkey).await; + } + } + }) + }); + + // Owner path: watch the room's owner-loss / owner-drain signals. Fenced loss + // and intentional drain both close local owner clients for rejoin and forget + // the local generation floor so the fresh generation is accepted. The cause + // distinction is carried on the remote control streams; locally the action + // is the same WS teardown. Silent on ordinary client leave. + let owner_teardown_task = if owner_lost.is_some() || owner_draining.is_some() { + let fence = Arc::clone( + &state + .mesh() + .expect("owner teardown watcher only exists when mesh owner state exists") + .audio_fence, + ); + let owner_cancel = cancel.clone(); + Some(tokio::spawn(async move { + let lost_fired = async { + match &owner_lost { + Some(token) => token.cancelled().await, + None => std::future::pending().await, + } + }; + let drain_fired = async { + match &owner_draining { + Some(token) => token.cancelled().await, + None => std::future::pending().await, + } + }; + tokio::select! { + _ = drain_fired => { + info!( + channel_id = %channel_id, + "huddle owner is draining — closing local client for rejoin" + ); + owner_cancel.cancel(); + fence.forget(channel_id); + } + _ = lost_fired => { + info!( + channel_id = %channel_id, + "huddle owner lost its lease — closing local client for rejoin" + ); + owner_cancel.cancel(); + fence.forget(channel_id); + } + _ = owner_cancel.cancelled() => {} + } + })) + } else { + None + }; + recv_loop( ws_recv, Arc::clone(&room), @@ -427,6 +730,7 @@ async fn handle_audio_connection( ctrl_tx, Arc::clone(&missed_pongs), cancel.clone(), + remote_session.as_mut(), ) .await; @@ -434,15 +738,29 @@ async fn handle_audio_connection( let _ = send_task.await; let _ = heartbeat_task.await; let _ = forward_task.await; + // The reader task owns the owner control stream; joining it here guarantees + // its clean-close (or teardown) completes before connection cleanup returns. + if let Some(reader_task) = reader_task { + let _ = reader_task.await; + } + // The owner teardown watcher is cancelled by `cancel.cancel()` above (or has + // already fired); join it so it settles before cleanup. + if let Some(owner_teardown_task) = owner_teardown_task { + let _ = owner_teardown_task.await; + } - // Atomic remove + end check: remove_peer_and_check_ended holds the + // Atomic owner remove + end check: remove_peer_and_check_ended holds the // AdmissionGuard lock across index recycling AND the is_empty + ended=true - // check. This is the SAME lock that add_peer holds across its ended check - // + insert. So they are mutually exclusive — no concurrent add_peer can - // succeed between the removal and the ended flag being set. - let (_, should_auto_end) = room - .remove_peer_and_check_ended(peer_id) - .unwrap_or((peer_index, false)); + // check. Ingress mirrors never archive authoritative huddle state; they + // remove locally and let the owner decide room lifetime. + let should_auto_end = if remote_session.is_some() { + room.remove_peer(peer_id); + false + } else { + room.remove_peer_and_check_ended(peer_id) + .map(|(_, ended)| ended) + .unwrap_or(false) + }; let left_msg = serde_json::json!({ "type": "left", @@ -450,7 +768,9 @@ async fn handle_audio_connection( "peer_index": peer_index, }) .to_string(); - room.broadcast_control(left_msg); + if remote_session.is_none() { + room.broadcast_control(left_msg); + } emit_participant_event( &state, @@ -462,6 +782,7 @@ async fn handle_audio_connection( ) .await; + let room_emptied; if should_auto_end { info!(channel_id = %channel_id, "audio room empty — auto-ending huddle"); @@ -473,9 +794,10 @@ async fn handle_audio_connection( Err(e) => { warn!(channel_id = %channel_id, "auto-archive failed, huddle stays alive: {e}"); room.clear_ended(); + room_emptied = false; } Ok(()) => { - state.audio_rooms.cleanup_if_empty(channel_id); + room_emptied = state.audio_rooms.cleanup_if_empty(channel_id); emit_participant_event( &state, @@ -489,7 +811,19 @@ async fn handle_audio_connection( } } } else { - state.audio_rooms.cleanup_if_empty(channel_id); + room_emptied = state.audio_rooms.cleanup_if_empty(channel_id); + } + + // Owner path: release this room's lease when the room empties, so a new + // owner can acquire and the renewer stops cleanly (silent, not owner-loss). + // Fenced on the generation this connection saw as owner: if the room + // emptied and a re-acquire installed a newer epoch in the gap, `release` + // is a no-op for the stale generation and leaves the live renewer running. + // Only the last leaver empties the room, so exactly one release fires. + if room_emptied { + if let (Some(mesh), Some(generation)) = (state.mesh(), owner_generation) { + mesh.owners.release(channel_id, generation); + } } info!( @@ -499,6 +833,66 @@ async fn handle_audio_connection( ); } +/// React to a non-owner huddle teardown signal read off the owner's control +/// stream: cancel the connection (which drives the client's WS to close so it +/// rejoins) and forget the local generation floor for this session. +/// +/// The `cause` is logged for observability but does not change behaviour — +/// every cause is recoverable by a rejoin, whether against a fresh owner +/// (`OwnerLost`/`StreamClosed`), a draining owner (`OwnerDraining`), or a room +/// that simply ended (`SessionEnded`). `forget` clears local stale-frame +/// suppression so the rejoin's fresh generation is accepted; it never +/// authorizes ownership — Redis fenced CAS remains the arbiter. +fn teardown_remote_huddle( + cause: crate::audio::join::HuddleTeardownCause, + channel_id: Uuid, + cancel: &CancellationToken, + fence: &crate::audio::mesh::GenerationFloor, +) { + info!( + channel_id = %channel_id, + ?cause, + "owner tore down cross-pod huddle session — closing client for rejoin" + ); + cancel.cancel(); + fence.forget(channel_id); +} + +/// Map an owner's registration rejection to the client-facing WS error, using +/// the same `code`s a same-pod join produces so a cross-pod client handles them +/// identically. Fence rejections carry their taxonomy code for observability. +fn remote_rejection_ws_error(reason: &crate::audio::join::RegisterRejection) -> serde_json::Value { + use crate::audio::join::RegisterRejection; + match reason { + RegisterRejection::RoomFull => serde_json::json!({ + "type": "error", "code": "room_full", + "message": "peer index space exhausted" + }), + RegisterRejection::RoomEnded => serde_json::json!({ + "type": "error", "code": "room_ended", "message": "huddle has ended" + }), + RegisterRejection::VersionMismatch { pinned, requested } => serde_json::json!({ + "type": "error", "code": "upgrade_required", + "message": format!( + "this huddle is using audio protocol v{pinned}; your client requested v{requested}" + ), + "pinned_version": pinned, + "requested_version": requested, + }), + RegisterRejection::Fenced(f) => serde_json::json!({ + "type": "error", "code": "join_rejected", + "message": "huddle join rejected", + "fence_reason": f.code(), + }), + } +} + +/// Receive loop: reads client frames and routes them. Local/owner joins fan +/// out through the local room; a non-owner join forwards to the huddle owner +/// via `remote_session`. Argument count reflects the pre-existing connection +/// wiring plus the one mesh session; a param struct would obscure more than it +/// clarifies at this single call site. +#[allow(clippy::too_many_arguments)] async fn recv_loop( mut ws_recv: futures_util::stream::SplitStream, room: Arc, @@ -507,6 +901,7 @@ async fn recv_loop( ctrl_tx: mpsc::Sender, missed_pongs: Arc, cancel: CancellationToken, + mut remote_session: Option<&mut crate::audio::join::RemoteHuddleSession>, ) { use crate::audio::wire::{FrameHeader, V2_HEADER_LEN}; @@ -567,7 +962,15 @@ async fn recv_loop( } } - room.broadcast_frame(peer_id, data); + // Non-owner path forwards the client's Opus to the + // huddle owner as a datagram (the owner is the sole + // fan-out authority); the owner-side room fans it back + // to every participant, including our co-located peers. + // Owner/local path fans out through the local room. + match remote_session.as_deref_mut() { + Some(session) => session.forward_media(&data), + None => room.broadcast_frame(peer_id, data), + } } Some(Ok(WsMessage::Text(text))) => { if text.len() > MAX_TEXT_FRAME_BYTES { diff --git a/crates/buzz-relay/src/audio/join.rs b/crates/buzz-relay/src/audio/join.rs new file mode 100644 index 0000000000..9c57a42c15 --- /dev/null +++ b/crates/buzz-relay/src/audio/join.rs @@ -0,0 +1,3021 @@ +//! Cross-pod huddle join coordination over the mesh `HuddleControl` profile. +//! +//! This is the control plane that decides, at join time, *which pod owns a +//! huddle* and wires a client into that owner's room — the counterpart to the +//! media datagram fan-out in [`super::mesh`]. It is the join path +//! [`super::handler`] calls once a client has authed and passed membership. +//! +//! ## Ownership decision (Redis is the arbiter, mesh is only a hint) +//! +//! A huddle's `session_id` is its `channel_id`. Exactly one pod owns it: the +//! holder of the Redis fenced CAS lease. On join we resolve ownership through +//! the [`HuddleDirectory`]: +//! +//! - **No live lease** → this pod acquires it and becomes owner +//! ([`JoinOutcome::LocalOwner`]). The client admits to a local [`Room`] as in +//! a single-pod huddle. +//! - **Lease held by us** → [`JoinOutcome::LocalOwner`] at the live generation. +//! - **Lease held by another pod** → [`JoinOutcome::RemoteOwner`]. The client +//! admits to a *local* room too, but the pod also opens a `HuddleControl` +//! stream to the owner and registers the client as a remote peer there so the +//! owner fans media back (see [`super::mesh`]). +//! +//! Membership never grants ownership: it may say "route to that pod," never +//! "take over." The owner side re-validates every registration's fence against +//! Redis on receipt — fencing at every hop, not just at the origin — so a lease +//! that changes between our lookup and the owner's receipt is caught there. +//! +//! ## `HuddleControl` payload schema (owned here) +//! +//! The mesh wire layer carries huddle-control bytes opaquely in +//! [`MeshStreamFrame::Data`](buzz_relay_mesh::MeshStreamFrame). Their layout is +//! [`HuddleControlMsg`], postcard-encoded. Non-owner → owner: +//! [`HuddleControlMsg::RegisterPeer`] / [`HuddleControlMsg::UnregisterPeer`]. +//! Owner → non-owner: [`HuddleControlMsg::PeerRegistered`] (assigned index) or +//! [`HuddleControlMsg::RegisterRejected`] (fence/admission failure surfaced to +//! the client as a join error, never a silent media drop). + +use std::sync::Arc; +use std::time::Duration; + +use buzz_core::CommunityId; +use buzz_relay_mesh::{ + FencedHeader, GoodbyeReason, MeshDatagram, MeshError, MeshStream, MeshStreamFrame, Profile, + RelayPeerTransport, RuntimeId, StreamHello, StreamRole, +}; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::debug; +use uuid::Uuid; + +use super::mesh::spawn_remote_peer_sink; +use super::room::{ + AdmissionError, AudioRoomManager, Room, RosterDelta as RoomRosterDelta, RosterPeer, +}; +use crate::tunnel::directory::{ReleaseResult, RenewResult, SessionDirectory, SessionLease}; + +/// The slice of the Redis fenced session directory the huddle join path needs. +/// +/// Implemented by the session-directory lane's `SessionDirectory` over the +/// Redis CAS lease (see [`crate::tunnel::directory`]). Kept as a trait so the +/// coordinator is unit-testable without Redis, and so the handler depends on a +/// capability rather than a concrete type. Every method is a fenced, +/// linearizable Redis operation — the arbiter of ownership. +#[async_trait::async_trait] +pub trait HuddleDirectory: Send + Sync { + /// Look up the live owner + generation for a huddle, or `None` if no lease + /// exists yet. + async fn owner_of( + &self, + community_id: CommunityId, + session_id: Uuid, + ) -> Result, MeshError>; + + /// Acquire ownership of a huddle if it is currently unowned. `owner` is the + /// runtime that would own the lease (this pod's mesh identity). Returns the + /// resulting ownership either way: `Acquired` when this pod took the lease + /// (carrying the full [`HuddleLease`] material so the owner side can renew / + /// release it), `Held` when another pod won the race (CAS lost) — a routing + /// hint only, so it carries the lighter [`Ownership`] snapshot. + async fn acquire( + &self, + community_id: CommunityId, + session_id: Uuid, + owner: RuntimeId, + ) -> Result; + + /// Renew an owned huddle lease. The renewer calls this on an interval to + /// hold the fenced lease; a [`HuddleRenewOutcome::Lost`] or an error is + /// owner-loss (Redis, the arbiter, no longer names this pod at this + /// generation). Mesh membership never enters here. + async fn renew(&self, lease: &HuddleLease) -> Result; + + /// Release an owned huddle lease on clean teardown. A + /// [`HuddleReleaseOutcome::NotOwner`] means the lease already moved — the + /// caller lost ownership before it could release, which is owner-loss. + async fn release(&self, lease: &HuddleLease) -> Result; + + /// Validate a fenced header against the live lease. Returns a typed + /// [`MeshError`] fence rejection when the frame is stale / unowned / + /// owner-mismatched — the caller surfaces this to the client as a join + /// rejection, never a media drop. + async fn validate( + &self, + community_id: CommunityId, + fenced: &FencedHeader, + ) -> Result<(), MeshError>; +} + +/// Bridges the concrete Redis-backed [`SessionDirectory`] to the huddle join +/// path's capability trait. Huddle sessions always use the +/// [`Profile::HuddleControl`] profile when acquiring a lease, so the profile is +/// fixed here rather than threaded through the join API. +#[async_trait::async_trait] +impl HuddleDirectory for crate::tunnel::directory::SessionDirectory { + async fn owner_of( + &self, + community_id: CommunityId, + session_id: Uuid, + ) -> Result, MeshError> { + let lease = self + .lookup(community_id, session_id) + .await + .map_err(|e| MeshError::Transport(e.to_string()))?; + Ok(lease.map(|l| Ownership { + owner_runtime_id: l.owner_runtime_id, + generation: l.generation, + })) + } + + async fn acquire( + &self, + community_id: CommunityId, + session_id: Uuid, + owner: RuntimeId, + ) -> Result { + use crate::tunnel::directory::AcquireResult; + let result = self + .acquire(community_id, session_id, owner, HUDDLE_CONTROL_PROFILE) + .await + .map_err(|e| MeshError::Transport(e.to_string()))?; + Ok(match result { + // Acquired: keep the full lease so the owner side can renew/release + // it. Redis is the arbiter — we retain its material, never rebuild + // authority from `{owner, generation}`. + AcquireResult::Acquired(l) => AcquireOutcome::Acquired(HuddleLease(l)), + AcquireResult::Exists(l) => AcquireOutcome::Held(Ownership { + owner_runtime_id: l.owner_runtime_id, + generation: l.generation, + }), + }) + } + + async fn renew(&self, lease: &HuddleLease) -> Result { + let result = SessionDirectory::renew(self, &lease.0) + .await + .map_err(|e| MeshError::Transport(e.to_string()))?; + Ok(match result { + RenewResult::Renewed(l) => HuddleRenewOutcome::Renewed(HuddleLease(l)), + RenewResult::Lost { .. } => HuddleRenewOutcome::Lost, + }) + } + + async fn release(&self, lease: &HuddleLease) -> Result { + let result = SessionDirectory::release(self, &lease.0) + .await + .map_err(|e| MeshError::Transport(e.to_string()))?; + Ok(match result { + ReleaseResult::Released(_) => HuddleReleaseOutcome::Released, + ReleaseResult::NotOwner { .. } => HuddleReleaseOutcome::NotOwner, + }) + } + + async fn validate( + &self, + community_id: CommunityId, + fenced: &FencedHeader, + ) -> Result<(), MeshError> { + self.validate_fenced_header(community_id, fenced).await + } +} + +/// A resolved ownership snapshot: which pod owns a huddle, at what generation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Ownership { + /// Runtime holding the Redis lease for this huddle. + pub owner_runtime_id: RuntimeId, + /// Fenced generation of this ownership epoch; monotonic per session. + pub generation: u64, +} + +/// Real, fenced lease material for a huddle this pod owns. +/// +/// Retained from [`HuddleDirectory::acquire`] so the owner side can renew and +/// release the exact `(owner_runtime_id, generation)` epoch Redis granted — +/// authority is never reconstructed from an `Ownership` snapshot or a mesh +/// membership hint. The inner [`SessionLease`] is the session-directory lane's +/// Redis-arbitrated lease; huddle code treats it as an opaque handle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HuddleLease(pub(crate) SessionLease); + +impl HuddleLease { + /// The fenced generation this lease owns. + pub fn generation(&self) -> u64 { + self.0.generation + } + + /// The runtime that owns this lease (this pod). + pub fn owner_runtime_id(&self) -> RuntimeId { + self.0.owner_runtime_id + } +} + +/// Result of a huddle-lease renewal. Loss is owner-loss: Redis no longer names +/// this pod at this generation, so the owner must tear down and let clients +/// rejoin against the new owner. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HuddleRenewOutcome { + /// Lease TTL extended; this pod still owns the generation (carries the + /// refreshed lease). + Renewed(HuddleLease), + /// Lease absent or moved to a different owner/generation — owner-loss. + Lost, +} + +/// Result of releasing a huddle lease on teardown. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HuddleReleaseOutcome { + /// Lease deleted; clean release by the owner. + Released, + /// Lease absent or already moved — the owner lost it before releasing. + NotOwner, +} + +/// Result of an ownership acquire attempt. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AcquireOutcome { + /// This pod created the lease and owns the returned generation. Carries the + /// full [`HuddleLease`] so the owner side can renew/release it. + Acquired(HuddleLease), + /// Another pod already holds the lease (CAS lost); route to it instead. + Held(Ownership), +} + +/// What the handler should do with a join, decided by ownership. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JoinOutcome { + /// This pod owns the huddle: admit the client to a local room directly, as + /// in a single-pod huddle. Cross-pod peers (if any) reach us over the mesh. + LocalOwner { + /// Fenced generation this pod owns — stamped on media it fans out. + generation: u64, + }, + /// Another pod owns the huddle: admit the client locally *and* register it + /// with the owner over `HuddleControl` so the owner fans media back. The + /// fenced header is pre-validated against the live lease; the owner + /// re-validates on receipt. + RemoteOwner { + /// Owner to open the `HuddleControl` stream to. + owner_runtime_id: RuntimeId, + /// Fenced generation of the owner's epoch. + generation: u64, + }, +} + +impl JoinOutcome { + /// The fenced header for frames this join produces, given the huddle's + /// session id (its channel id) and resolved owner. For a local-owner join + /// the owner is this pod (`local_runtime_id`); for a remote-owner join it + /// is the resolved owner. + pub fn fenced_header(&self, session_id: Uuid, local_runtime_id: RuntimeId) -> FencedHeader { + match *self { + JoinOutcome::LocalOwner { generation } => FencedHeader { + session_id, + generation, + owner_runtime_id: local_runtime_id, + }, + JoinOutcome::RemoteOwner { + owner_runtime_id, + generation, + } => FencedHeader { + session_id, + generation, + owner_runtime_id, + }, + } + } +} + +/// Outcome of [`resolve_join`]: the routing verdict plus, on the arm that +/// freshly acquired the lease, the real [`HuddleLease`] to install in the +/// [`HuddleOwnerRegistry`]. +/// +/// `acquired` is `Some` **only** when this call won the Redis CAS and minted a +/// new lease (`JoinOutcome::LocalOwner` via acquire). The steady-state owner +/// arm and every remote-owner arm carry `None`: a steady-state owner reuses the +/// registry's existing renewer rather than rebuilding authority from the +/// generation snapshot, honoring the authority-not-snapshot rule. +#[derive(Debug)] +pub struct ResolvedJoin { + /// What the handler should do with the join. + pub outcome: JoinOutcome, + /// The freshly-acquired owner lease, present only on the acquire arm. + pub acquired: Option, +} + +/// Resolve who owns a huddle and how this pod should join it. +/// +/// The ownership plane is Redis-arbitrated: we look up the live lease and, only +/// if the huddle is unowned, attempt to acquire it (losing the CAS gracefully +/// hands us a `RemoteOwner` outcome pointing at the winner). A remote-owner +/// outcome is fence-validated against the live lease before we route to it, so +/// a caller never opens a control stream on a header Redis would reject. +/// +/// On the arm that freshly acquires the lease, the real [`HuddleLease`] is +/// returned in [`ResolvedJoin::acquired`] so the caller installs one per-room +/// renewer in the [`HuddleOwnerRegistry`] — the authority is the lease Redis +/// minted, never rebuilt from the generation snapshot. +/// +/// `local_runtime_id` is this pod's mesh identity, used to tell "I own it" from +/// "someone else owns it." +pub async fn resolve_join( + directory: &D, + community_id: CommunityId, + session_id: Uuid, + local_runtime_id: RuntimeId, +) -> Result { + // Look up the live lease first: the common steady-state case is an already + // owned huddle, and we avoid an acquire attempt (and its generation INCR + // race window) when a live owner already exists. + let ownership = match directory.owner_of(community_id, session_id).await? { + Some(o) => o, + None => { + // Unowned: try to take it. A lost CAS means a peer beat us to it + // between our lookup and acquire — treat the winner as the owner. + match directory + .acquire(community_id, session_id, local_runtime_id) + .await? + { + AcquireOutcome::Acquired(o) => { + let generation = o.generation(); + return Ok(ResolvedJoin { + outcome: JoinOutcome::LocalOwner { generation }, + acquired: Some(o), + }); + } + AcquireOutcome::Held(o) => o, + } + } + }; + + if ownership.owner_runtime_id == local_runtime_id { + return Ok(ResolvedJoin { + outcome: JoinOutcome::LocalOwner { + generation: ownership.generation, + }, + acquired: None, + }); + } + + // Remote owner: validate the fence against the live lease before we commit + // to routing there. This is the origin-side hop of the fencing law; the + // owner re-validates on receipt. + let fenced = FencedHeader { + session_id, + generation: ownership.generation, + owner_runtime_id: ownership.owner_runtime_id, + }; + directory.validate(community_id, &fenced).await?; + + Ok(ResolvedJoin { + outcome: JoinOutcome::RemoteOwner { + owner_runtime_id: ownership.owner_runtime_id, + generation: ownership.generation, + }, + acquired: None, + }) +} + +/// How long to wait between re-resolves while a `LocalOwner` snapshot has no +/// live registry entry yet, and how many times to try before failing closed. +/// The CAS winner installs its registry entry immediately after the (fast, +/// local) `add_peer`, so the ambiguous window is tiny; ~500 ms of bounded +/// polling covers it with wide margin without ever proceeding ownerless. +const OWNER_READY_RETRY_INTERVAL: Duration = Duration::from_millis(20); +const OWNER_READY_MAX_ATTEMPTS: u32 = 25; + +/// Resolve a join and, on the steady-state `LocalOwner` reuse arm, ensure a +/// live owner renewer actually exists before the caller admits a local owner +/// peer. +/// +/// [`resolve_join`] returns `LocalOwner { generation }` with `acquired = None` +/// whenever Redis names this pod the owner but this call did not mint the lease +/// — the steady-state reuse arm. Reuse is only correct when a live entry exists +/// in the [`HuddleOwnerRegistry`]: that entry carries the `lost` signal the +/// admitted peer's owner-loss watcher selects on. If the CAS winner has +/// resolved but not yet installed its entry (the window between its +/// `resolve_join` and its post-`add_peer` `attach`), a naive reuse would admit +/// a local owner peer with **no** loss watcher — it would fan media at a +/// generation it cannot observe losing, the exact consumer-#2 split-brain the +/// fence exists to prevent. +/// +/// So the registry lookup — never the `resolve_join` snapshot — gates reuse: +/// when the snapshot says `LocalOwner`/`acquired = None` but no live entry +/// exists, re-resolve in a bounded loop. Each retry lands on one of: +/// - the winner installed in the meantime → live entry → **reuse**; +/// - the room emptied and released underneath us → `owner_of` is now unowned → +/// our re-acquire wins CAS → `acquired = Some` → **fresh lease**; +/// - still the ambiguous window → sleep and retry. +/// +/// If the loop exhausts we fail the join closed (a transient contention error, +/// surfaced to the client exactly like a lost CAS) rather than ever admit an +/// ownerless owner peer. The CAS-winning and remote-owner arms return +/// immediately — only the reuse arm can loop. +pub async fn resolve_join_owner_ready( + directory: &D, + community_id: CommunityId, + session_id: Uuid, + local_runtime_id: RuntimeId, + owners: &HuddleOwnerRegistry, +) -> Result { + for _ in 0..OWNER_READY_MAX_ATTEMPTS { + let resolved = resolve_join(directory, community_id, session_id, local_runtime_id).await?; + + // Only the steady-state reuse arm (LocalOwner minted by another + // connection) can be ambiguous. The CAS winner (acquired = Some) and + // every remote-owner arm are authoritative — return them as-is. + match (&resolved.outcome, &resolved.acquired) { + (JoinOutcome::LocalOwner { .. }, None) => { + if owners.lost_for(session_id).is_some() { + return Ok(resolved); // live entry → reuse is safe + } + // Ambiguous window: winner not yet attached (or room released + // underneath us). Wait and re-resolve — the retry either finds + // the installed entry or wins a fresh CAS. + tokio::time::sleep(OWNER_READY_RETRY_INTERVAL).await; + } + _ => return Ok(resolved), + } + } + + // Exhausted: fail closed. Never admit a local owner peer with no live + // renewer — that is the ownerless split-brain this loop exists to prevent. + Err(MeshError::Transport(format!( + "huddle owner not ready for session {session_id} after {OWNER_READY_MAX_ATTEMPTS} attempts" + ))) +} + +/// Renewal cadence for an owned huddle lease. Mirrors the reliable lane +/// (`crate::tunnel::reliable::DEFAULT_RENEW_INTERVAL`): renew every 10s against +/// the directory's 30s TTL, giving three renew attempts per lease lifetime. +const DEFAULT_HUDDLE_RENEW_INTERVAL: Duration = Duration::from_secs(10); + +/// Background huddle-lease renewer plus an observable ownership-loss signal. +/// +/// The owner-side analog of the reliable lane's +/// `crate::tunnel::reliable::ReliableLeaseRenewer`, with the identical +/// contract: `lost` is cancelled **only** on fenced owner-loss — +/// [`HuddleRenewOutcome::Lost`], a renew error, a release +/// [`HuddleReleaseOutcome::NotOwner`], or a release error on a non-caller-loss +/// exit. Caller-initiated `cancel` (client drain, clean leave, shutdown) is +/// normal teardown and stays silent. +pub struct HuddleLeaseRenewer { + /// Worker task. Await during teardown if the caller needs release + /// completion. + pub task: JoinHandle<()>, + /// Cancelled when renewal observes loss / `NotOwner` or a renewal error. + /// Owner-side `serve_control_loop`s select on this to emit a proactive + /// `Goodbye(StaleGeneration)` so non-owner pods rejoin against the new + /// owner. + pub lost: CancellationToken, +} + +/// Start background renewal for an owner-held huddle lease and return a loss +/// signal consumers can observe. +/// +/// A mirror of `crate::tunnel::reliable::spawn_observable_renewer`: renew the +/// fenced lease on an interval; on owner-loss trip `lost` and stop; on +/// caller-`cancel` release cleanly and stay silent. `directory` is the fenced +/// arbiter — the sole authority for whether this pod still owns the generation. +pub fn spawn_observable_huddle_renewer( + directory: Arc, + lease: HuddleLease, + cancel: CancellationToken, +) -> HuddleLeaseRenewer { + spawn_huddle_renewer_with_interval(directory, lease, cancel, DEFAULT_HUDDLE_RENEW_INTERVAL) +} + +fn spawn_huddle_renewer_with_interval( + directory: Arc, + lease: HuddleLease, + cancel: CancellationToken, + renew_interval: Duration, +) -> HuddleLeaseRenewer { + let lost = CancellationToken::new(); + let lost_for_task = lost.clone(); + let task = tokio::spawn(async move { + let owner_runtime_id = lease.owner_runtime_id(); + let generation = lease.generation(); + let mut interval = tokio::time::interval(renew_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let caller_cancelled = loop { + tokio::select! { + _ = cancel.cancelled() => break true, + _ = interval.tick() => { + match directory.renew(&lease).await { + Ok(HuddleRenewOutcome::Renewed(_)) => {} + Ok(HuddleRenewOutcome::Lost) => { + tracing::warn!( + %owner_runtime_id, + generation, + "huddle lease renewal lost" + ); + lost_for_task.cancel(); + break false; + } + Err(err) => { + tracing::warn!( + %owner_runtime_id, + generation, + error = %err, + "huddle lease renewal failed" + ); + lost_for_task.cancel(); + break false; + } + } + } + } + }; + + match directory.release(&lease).await { + Ok(HuddleReleaseOutcome::Released) => {} + Ok(HuddleReleaseOutcome::NotOwner) => { + tracing::warn!( + %owner_runtime_id, + generation, + "huddle lease release found non-owner" + ); + lost_for_task.cancel(); + } + Err(err) => { + tracing::warn!( + %owner_runtime_id, + generation, + error = %err, + "huddle lease release failed" + ); + // A release error after a clean caller cancel is logged but not + // owner-loss: we are shutting down anyway. On any other exit + // (already lost) the signal has already tripped; trip it here + // too so a loss that only surfaces at release is never silent. + if !caller_cancelled { + lost_for_task.cancel(); + } + } + } + }); + + HuddleLeaseRenewer { task, lost } +} + +/// Per-room owner-lease coordination shared by the WS-join owner path and the +/// [`HuddleControlAcceptor`]. +/// +/// The huddle-lease renewer is a per-*room* resource, not per-connection: the +/// pod that wins the Redis CAS holds one lease for the room, and N local owner +/// joiners must not each spawn a renewer racing to renew/release it. This +/// registry holds that single owner state, keyed by `session_id`, so: +/// - the acquiring WS connection installs one renewer ([`Self::attach`]); +/// - later owner joiners and the inbound control acceptor read its `lost` +/// signal ([`Self::lost_for`]) instead of rebuilding authority from a +/// generation snapshot; +/// - when the room empties the owner connection releases the lease +/// ([`Self::release`]), cancelling the renewer. +/// +/// **The registry, not the `resolve_join` snapshot, gates reuse.** A late +/// joiner whose ownership lookup says "owned by us" but finds no live entry +/// (the room emptied and released underneath it) must re-acquire rather than +/// adopt a torn-down lease — `lost_for` returning `None` is that signal. +#[derive(Default)] +pub struct HuddleOwnerRegistry { + entries: DashMap, + draining: std::sync::atomic::AtomicBool, +} + +struct HuddleOwnerEntry { + /// Owner-loss signal fanned into every control loop and owner WS peer for + /// the room. Cancelled by the renewer on fenced loss. + lost: CancellationToken, + /// Owner-drain signal fanned into every control loop and owner WS peer for + /// the room. Cancelled by [`Self::drain`] so peers close with + /// `Goodbye(Draining)` rather than the fenced-loss `StaleGeneration` path. + draining: CancellationToken, + /// Caller-cancel handed to the renewer; cancelled by [`Self::release`] on + /// room-empty so the lease is released cleanly (silent, not owner-loss), and + /// by [`Self::drain`] after the drain signal so Redis releases the fenced + /// lease promptly. + cancel: CancellationToken, + /// Generation this entry's lease owns. `release`/`drain` are fenced on it so + /// a stale room-empty/drain cannot tear down a newer epoch a re-acquire + /// installed. + generation: u64, +} + +/// Owner-side signals for one huddle epoch. Returned atomically from attach so +/// the CAS winner cannot miss a concurrent drain between installing the owner +/// entry and looking the drain token back up. +pub struct HuddleOwnerSignals { + /// Fenced-loss signal. + pub lost: CancellationToken, + /// Intentional-drain signal. + pub draining: CancellationToken, +} + +impl HuddleOwnerRegistry { + /// Empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Whether this runtime has begun shutdown drain. Once true, new huddle + /// owner admissions fail closed even if they raced an earlier Redis lookup. + pub fn is_draining(&self) -> bool { + self.draining.load(std::sync::atomic::Ordering::Acquire) + } + + /// The room's owner-loss signal, or `None` when this pod holds no live + /// owner lease for the session. The acceptor passes this into + /// `serve_control_loop`; a steady-state owner joiner uses it to reuse the + /// existing renewer instead of acquiring again. + pub fn lost_for(&self, session_id: Uuid) -> Option { + self.entries.get(&session_id).map(|e| e.lost.clone()) + } + + /// The room's owner-drain signal, or `None` when this pod holds no live + /// owner lease for the session. Cancelled by [`Self::drain`] / [`Self::drain_all`] + /// to close local owner WS peers and emit `Goodbye(Draining)` to remote pods. + pub fn drain_for(&self, session_id: Uuid) -> Option { + self.entries.get(&session_id).map(|e| e.draining.clone()) + } + + /// Install the single per-room renewer for a freshly-acquired lease and + /// return its `lost` signal. + /// + /// Called only on the acquire path — Redis CAS admits exactly one + /// `Acquired` per generation, so at most one caller installs. If a live + /// entry already exists (a re-acquire racing a not-yet-released prior + /// epoch) the existing renewer wins and the just-acquired lease is released + /// by cancelling a throwaway renewer, so no lease leaks. + pub fn attach( + &self, + session_id: Uuid, + directory: Arc, + lease: HuddleLease, + ) -> CancellationToken { + self.attach_signals(session_id, directory, lease).lost + } + + /// Install the single per-room renewer and return both owner-side signals. + /// The returned pair is captured from the entry while it is live, so the CAS + /// winner cannot miss a concurrent drain between attach and a separate + /// `drain_for` lookup. + pub fn attach_signals( + &self, + session_id: Uuid, + directory: Arc, + lease: HuddleLease, + ) -> HuddleOwnerSignals { + if self.is_draining() { + let cancel = CancellationToken::new(); + cancel.cancel(); + spawn_observable_huddle_renewer(directory, lease, cancel); + let draining = CancellationToken::new(); + draining.cancel(); + return HuddleOwnerSignals { + lost: CancellationToken::new(), + draining, + }; + } + let generation = lease.generation(); + if let Some(existing) = self.entries.get(&session_id) { + // A live entry already owns this room; release our extra lease + // cleanly rather than leaving two renewers on one session. + let cancel = CancellationToken::new(); + cancel.cancel(); + spawn_observable_huddle_renewer(directory, lease, cancel); + return HuddleOwnerSignals { + lost: existing.lost.clone(), + draining: existing.draining.clone(), + }; + } + let cancel = CancellationToken::new(); + let renewer = spawn_observable_huddle_renewer(directory, lease, cancel.clone()); + let draining = CancellationToken::new(); + let lost = renewer.lost.clone(); + self.entries.insert( + session_id, + HuddleOwnerEntry { + lost: lost.clone(), + draining: draining.clone(), + cancel: cancel.clone(), + generation, + }, + ); + // Close the check→insert race with drain_all(): if shutdown began after + // the first check but before publication, retract this exact epoch and + // release its lease. A newer epoch, if any, is generation-fenced. + if self.is_draining() { + self.entries.remove_if(&session_id, |_, entry| { + if entry.generation == generation { + entry.draining.cancel(); + entry.cancel.cancel(); + true + } else { + false + } + }); + draining.cancel(); + cancel.cancel(); + } + HuddleOwnerSignals { lost, draining } + } + + /// Release the room's owner lease on room-empty: cancel the renewer's + /// caller-token so it releases cleanly (silent) and drop the entry. + /// + /// Fenced on `generation`: a stale caller cannot tear down a newer epoch a + /// re-acquire installed between this caller's room-empty and its release. + pub fn release(&self, session_id: Uuid, generation: u64) { + self.entries.remove_if(&session_id, |_, entry| { + if entry.generation == generation { + entry.cancel.cancel(); + true + } else { + false + } + }); + } + + /// Drain a room this pod owns: first fan out an explicit draining signal to + /// local owner WS peers and remote control streams, then cancel the renewer + /// so it CAS-releases the fenced lease. Fenced on generation exactly like + /// [`Self::release`]; drain never transfers ownership, it only clears this + /// pod's lease so rejoiners acquire through Redis. + pub fn drain(&self, session_id: Uuid, generation: u64) -> bool { + let mut drained = false; + self.entries.remove_if(&session_id, |_, entry| { + if entry.generation == generation { + entry.draining.cancel(); + entry.cancel.cancel(); + drained = true; + true + } else { + false + } + }); + drained + } + + /// Drain every room currently owned by this runtime. Used by SIGTERM + /// choreography after readiness has flipped and mesh membership is marked + /// draining. Each individual room remains generation-fenced by [`Self::drain`]. + pub fn drain_all(&self) -> usize { + self.draining + .store(true, std::sync::atomic::Ordering::Release); + let rooms: Vec<(Uuid, u64)> = self + .entries + .iter() + .map(|entry| (*entry.key(), entry.generation)) + .collect(); + rooms + .into_iter() + .filter(|(session_id, generation)| self.drain(*session_id, *generation)) + .count() + } + + /// Install an entry with a caller-supplied `lost` token and no renewer, for + /// tests that exercise the fan-out to the control loop / WS peers in + /// isolation from the (separately tested) renewer timing. + #[cfg(test)] + fn install_for_test(&self, session_id: Uuid, generation: u64) -> CancellationToken { + let lost = CancellationToken::new(); + self.entries.insert( + session_id, + HuddleOwnerEntry { + lost: lost.clone(), + draining: CancellationToken::new(), + cancel: CancellationToken::new(), + generation, + }, + ); + lost + } +} + +/// `HuddleControl` stream payload, carried in +/// [`MeshStreamFrame::Data`](buzz_relay_mesh::MeshStreamFrame)`.payload`, +/// postcard-encoded. This schema is owned by the huddle lane; the mesh wire +/// layer treats it as opaque bytes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum HuddleControlMsg { + /// Non-owner → owner: register a local client as a remote peer in the + /// owner's room. The owner allocates the `peer_index`. + RegisterPeer { + /// The community the huddle belongs to, as its raw UUID. Rides the + /// control frame because the mesh dispatch layer is community-agnostic — + /// `Hello` and the fenced header carry no community — yet the owner's + /// Redis fence key is `(community_id, session_id)`. Carried as a `Uuid`, + /// not a [`CommunityId`], on purpose: `CommunityId` is deliberately + /// non-deserializable so it can never be minted from client input. This + /// mesh frame is server-to-server, and the owner reconstitutes the + /// `CommunityId` explicitly via `from_uuid` before fencing. The + /// assertion is self-verifying, not trusted: `validate` looks up the key + /// the owner's own `acquire` created, so a wrong community finds no lease + /// and is rejected (`no_active_lease`) *before* any room mutation. The + /// owner latches the community from the first frame and rejects any + /// later frame on the same stream that names a different one. + community_id: Uuid, + /// Nostr pubkey hex of the joining client. + pubkey: String, + /// Huddle audio protocol version the client negotiated; the owner's + /// room is pinned to one version and rejects mismatches. + protocol_version: u8, + }, + /// Owner → non-owner: the client is registered; here is its assigned index. + PeerRegistered { + /// Pubkey the registration was for (echoed for correlation). + pubkey: String, + /// Owner-allocated 0..=254 index; the sole allocator is the owner, so + /// indices never collide across pods. + peer_index: u8, + /// Complete authoritative roster after this admission. This is in the + /// registration reply so no media/client identity can precede it. + roster: RosterSnapshot, + }, + /// Owner → non-owner: complete authoritative state at `revision`. + RosterSnapshot { + /// Owner-monotonic roster revision. + revision: u64, + /// Complete authoritative participants. + peers: Vec, + }, + /// Owner → non-owner: one ordered roster mutation. A revision gap is + /// recovered by `RosterResync`, never applied speculatively. + RosterDelta { + /// Owner-monotonic roster revision. + revision: u64, + /// Newly admitted peer, when this is a join. + joined: Option, + /// Removed peer, when this is a leave. + left: Option, + }, + /// Non-owner → owner: request a complete snapshot after detecting a gap. + RosterResync, + /// Owner → non-owner: registration refused. Surfaced to the client as a + /// join error (e.g. `room_full`, `upgrade_required`, or a fence rejection), + /// never a silent media drop. + RegisterRejected { + /// Pubkey the registration was for (echoed for correlation). + pubkey: String, + /// Machine-readable reason, matching the single-pod WS error `code`s + /// where applicable (`room_full`, `room_ended`, `upgrade_required`) or + /// a fence reason (`stale_generation`, `no_active_lease`, + /// `owner_mismatch`, `future_generation`). + reason: RegisterRejection, + }, + /// Non-owner → owner: the local client left; drop its remote peer. + UnregisterPeer { + /// Pubkey of the departing client. + pubkey: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// One participant in the authoritative owner roster. +pub struct RosterEntry { + /// Nostr pubkey hex. + pub pubkey: String, + /// Owner-assigned media routing index. + pub peer_index: u8, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// Complete authoritative roster at one owner revision. +pub struct RosterSnapshot { + /// Owner-monotonic roster revision. + pub revision: u64, + /// Complete participants at this revision. + pub peers: Vec, +} + +impl From for RosterEntry { + fn from(peer: RosterPeer) -> Self { + Self { + pubkey: peer.pubkey, + peer_index: peer.peer_index, + } + } +} + +/// Why an owner refused a remote-peer registration. Mirrors the single-pod +/// admission failures plus the fence-rejection taxonomy, so a cross-pod join +/// surfaces the same client-facing error a same-pod join would. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RegisterRejection { + /// Owner's room hit the peer cap / exhausted the index space. + RoomFull, + /// Owner's room has ended (auto-ended or archived). + RoomEnded, + /// Owner's room is pinned to a different protocol version. + VersionMismatch { + /// Version the owner's room is pinned to. + pinned: u8, + /// Version the joining client requested. + requested: u8, + }, + /// The registration's fence was rejected by Redis on the owner. Carries the + /// fence reason so `/_mesh` and the client see the same taxonomy the media + /// path uses. + Fenced(FenceRejection), +} + +/// The Redis fence-rejection reasons, as a serializable enum for the +/// `HuddleControl` wire (the crate's [`MeshError`] fence variants are not +/// `Serialize`). Kept 1:1 with those variants so nothing is lost across the +/// wire. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum FenceRejection { + /// Frame generation is below the known floor. + StaleGeneration, + /// No live lease exists for the session. + NoActiveLease, + /// Frame owner does not match the live lease owner. + OwnerMismatch, + /// Frame generation does not match the live lease generation. + FutureGeneration, +} + +impl FenceRejection { + /// Classify a [`MeshError`] fence variant. Returns `None` for non-fence + /// errors (transport, encode/decode) — those are not registration + /// rejections and are handled as stream failures by the caller. + pub fn from_mesh_error(err: &MeshError) -> Option { + match err { + MeshError::StaleGeneration { .. } => Some(Self::StaleGeneration), + MeshError::NoActiveLease { .. } => Some(Self::NoActiveLease), + MeshError::OwnerMismatch { .. } => Some(Self::OwnerMismatch), + MeshError::FutureGeneration { .. } => Some(Self::FutureGeneration), + _ => None, + } + } + + /// Stable machine-readable code, matching the media path / `/_mesh` + /// taxonomy (`stale_generation` | `no_active_lease` | `owner_mismatch` | + /// `future_generation`). + pub fn code(&self) -> &'static str { + match self { + Self::StaleGeneration => "stale_generation", + Self::NoActiveLease => "no_active_lease", + Self::OwnerMismatch => "owner_mismatch", + Self::FutureGeneration => "future_generation", + } + } +} + +/// Encode a [`HuddleControlMsg`] for a `MeshStreamFrame::Data` payload. +pub fn encode_control(msg: &HuddleControlMsg) -> Result, MeshError> { + postcard::to_allocvec(msg).map_err(MeshError::Encode) +} + +/// Decode a `HuddleControl` `Data` payload back into a [`HuddleControlMsg`]. +pub fn decode_control(bytes: &[u8]) -> Result { + postcard::from_bytes(bytes).map_err(MeshError::Decode) +} + +/// The tunnel profile these control messages ride. `HuddleControl` is a +/// reliable stream — a dropped roster delta is an unrecoverable peer-index +/// desync, so it never rides datagrams. +pub const HUDDLE_CONTROL_PROFILE: Profile = Profile::HuddleControl; + +// ── Owner-side HuddleControl accept path ───────────────────────────────────── +// +// The owner pod hosts the real [`Room`]. When a *non-owner* pod opens a +// `HuddleControl` stream and registers a client, the owner admits that client +// as an ordinary [`AudioPeer`] whose `audio_tx` is drained by +// [`super::mesh::spawn_remote_peer_sink`] back to the non-owner pod as +// datagrams. `Room` never learns about the mesh — a remote participant looks +// exactly like a local one to fan-out. + +/// Owner-side handler for inbound `HuddleControl` streams. +/// +/// One instance per relay; the boot-seam dispatcher routes every +/// `Profile::HuddleControl` session stream to [`Self::accept_inbound`]. It is +/// the counterpart to Perci's reliable-stream acceptor — same +/// `accept_inbound(from, hello, stream)` shape, different profile and body. +/// The community is not a handshake parameter: it rides the first stateful +/// frame and is self-verified by the fence (see [`Self::serve_control_loop`]). +pub struct HuddleControlAcceptor { + rooms: Arc, + transport: Arc, + directory: Arc, + local_runtime_id: RuntimeId, + /// Per-room owner state. The acceptor reads the room's owner-loss signal + /// from here so every inbound control loop for a room this pod owns fans + /// the *same* renewer's `lost` — the loss surfaces as a proactive + /// `Goodbye(StaleGeneration)` to each non-owner pod. + owners: Arc, +} + +impl HuddleControlAcceptor { + /// Build the acceptor. `directory` is the fenced arbiter (re-validated on + /// every registration); `transport` is used to open the media datagram + /// sink back to each registering pod; `owners` supplies each room's + /// owner-loss signal for the control-loop fan-out. + pub fn new( + rooms: Arc, + transport: Arc, + directory: Arc, + local_runtime_id: RuntimeId, + owners: Arc, + ) -> Self { + Self { + rooms, + transport, + directory, + local_runtime_id, + owners, + } + } + + /// Accept and validate an inbound `HuddleControl` stream, then serve its + /// register/unregister control loop until the stream closes. + /// + /// The `Hello` is validated **structurally only** — it admits no peer and + /// touches no room, so it is deliberately not Redis-fenced: the fence key is + /// `(community_id, session_id)` and the community is not known until the + /// first `RegisterPeer` frame carries it. Structural checks are: the claimed + /// sender is the authenticated peer, the role is a `HuddleControl` session, + /// and this pod is the header's named owner. The first *stateful* operation + /// (`RegisterPeer`) is where the Redis fence runs, before `room.add_peer` — + /// see [`Self::serve_control_loop`]. Matches the dispatcher callback shape + /// `(from, hello, stream)`; no `community_id` param — community rides the + /// wire and is self-verified by the fence. + /// + /// The owner-loss fan-out is read from the [`HuddleOwnerRegistry`] keyed by + /// the stream's `session_id`: if this pod holds a live owner lease for the + /// room the loop selects on that renewer's `lost` and emits a proactive + /// [`GoodbyeReason::StaleGeneration`] before teardown when the lease is + /// lost, so non-owner pods rejoin against the new owner. If no live entry + /// exists (the room emptied and released, or the entry has not landed yet) + /// the loop degenerates to recv-only, exactly the prior behavior. + pub async fn accept_inbound( + &self, + from: RuntimeId, + hello: StreamHello, + stream: MeshStream, + ) -> Result<(), MeshError> { + if hello.sender != from { + return Err(MeshError::Transport(format!( + "huddle-control hello.sender {} != authenticated peer {from}", + hello.sender + ))); + } + let StreamRole::Session { fenced, profile } = hello.role else { + return Err(MeshError::Transport( + "huddle-control stream Hello was not a session role".into(), + )); + }; + if profile != Profile::HuddleControl { + return Err(MeshError::Transport(format!( + "huddle-control acceptor got profile {profile:?}" + ))); + } + // Structural owner check: reject an obviously-misrouted stream cheaply, + // before serving any frame. This is not the fence — the authoritative + // Redis re-validation happens per control frame in the loop, keyed by + // the community the frame carries. + if fenced.owner_runtime_id != self.local_runtime_id { + return Err(MeshError::OwnerMismatch { + session_id: fenced.session_id, + generation: fenced.generation, + frame_owner_runtime_id: fenced.owner_runtime_id, + current_owner_runtime_id: self.local_runtime_id, + }); + } + + let lost = self.owners.lost_for(fenced.session_id); + let draining = self.owners.drain_for(fenced.session_id); + self.serve_control_loop(from, fenced, stream, lost, draining) + .await + } + + /// Serve register/unregister frames for one non-owner pod's stream. + /// + /// The community is learned from the first `RegisterPeer` frame and latched + /// for the life of the stream — a stream is one non-owner pod's view of one + /// huddle, so exactly one community applies. Later frames naming a different + /// community are rejected. The Redis fence runs on `RegisterPeer` *before* + /// `room.add_peer` (validate-before-admit): a frame asserting the wrong + /// community keys a lease that does not exist and is rejected before any + /// state mutation. `UnregisterPeer` only removes entries from this stream's + /// own `registered` map, so it needs no fence — it cannot mutate another + /// community's room. + /// + /// Peers this stream registers are tracked so a stream close (the non-owner + /// pod went away) tears them all down — no leaked remote peers holding + /// index slots in the owner's room. + /// `lost`, when `Some`, is an owner-loss signal (from the huddle-lease + /// renewer): if it fires the loop stops and sends a proactive + /// [`GoodbyeReason::StaleGeneration`] before teardown, so the non-owner pod + /// rejoins against the new owner instead of waiting for the stream to fault. + /// `None` disables the arm and preserves the recv-only behavior. + async fn serve_control_loop( + &self, + from: RuntimeId, + fenced: FencedHeader, + mut stream: MeshStream, + lost: Option, + draining: Option, + ) -> Result<(), MeshError> { + let session_id = fenced.session_id; + // pubkey -> peer_id, for UnregisterPeer and teardown on stream close. + let mut registered: std::collections::HashMap = + std::collections::HashMap::new(); + // Community (raw UUID) latched from the first RegisterPeer; every later + // frame must agree. `None` until the first register arrives. + let mut stream_community: Option = None; + let mut roster_rx: Option> = None; + + // Owner teardown latch: set when `lost`/`draining` fires so teardown + // sends the matching proactive Goodbye. A stream faulting on its own + // leaves this empty and the close stays silent, as before. + let mut teardown_reason: Option = None; + + let result = loop { + // A future that never resolves when there is no loss signal, so the + // `select!` degenerates to a plain recv for the `None` case. + let lost_fired = async { + match &lost { + Some(token) => token.cancelled().await, + None => std::future::pending().await, + } + }; + let drain_fired = async { + match &draining { + Some(token) => token.cancelled().await, + None => std::future::pending().await, + } + }; + let roster_event = async { + match &mut roster_rx { + Some(rx) => Some(rx.recv().await), + None => std::future::pending().await, + } + }; + let frame = tokio::select! { + _ = drain_fired => { + teardown_reason = Some(GoodbyeReason::Draining); + break Ok(()); + } + _ = lost_fired => { + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } + event = roster_event => { + let Some(event) = event else { + continue; + }; + let msg = match event { + Ok(delta) => roster_delta_msg(delta), + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + let Some(room) = self.rooms.get(session_id) else { + break Ok(()); + }; + roster_snapshot_msg(&room) + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break Ok(()), + }; + stream.send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&msg)?, + }).await?; + continue; + } + frame = stream.recv_frame() => frame, + }; + let msg = match frame { + Ok(Some(MeshStreamFrame::Data { fenced: f, payload })) => { + // Every control frame must carry the same fenced header the + // Hello did: a lease that moves mid-stream (owner or + // generation change) rejects subsequent frames. + if f != fenced { + break Err(MeshError::OwnerMismatch { + session_id, + generation: f.generation, + frame_owner_runtime_id: f.owner_runtime_id, + current_owner_runtime_id: self.local_runtime_id, + }); + } + match decode_control(&payload) { + Ok(m) => m, + Err(e) => break Err(e), + } + } + Ok(Some(MeshStreamFrame::Goodbye { .. })) | Ok(None) => break Ok(()), + Ok(Some(other)) => { + break Err(MeshError::Transport(format!( + "huddle-control stream got unexpected frame {other:?}" + ))); + } + Err(e) => break Err(e), + }; + + match msg { + HuddleControlMsg::RegisterPeer { + community_id, + pubkey, + protocol_version, + } => { + // Latch the community on first receipt; reject any later + // frame that names a different one (tenant-boundary guard). + match stream_community { + None => stream_community = Some(community_id), + Some(latched) if latched != community_id => { + break Err(MeshError::Transport(format!( + "huddle-control stream community changed {latched} -> {community_id}" + ))); + } + Some(_) => {} + } + // Reconstitute the server-trusted `CommunityId` from the wire + // UUID — explicit and localized, honoring `CommunityId`'s + // no-client-input invariant — then fence. + let community = CommunityId::from_uuid(community_id); + // Validate-before-admit: the Redis fence keyed by the + // asserted community must pass before any room mutation. A + // wrong community keys a lease the owner never wrote → a + // typed fence rejection, so no peer is admitted and the + // client sees the same taxonomy a same-pod join would. A + // *non-fence* validate error (Redis unreachable, decode) is + // not a clean rejection — it tears the stream down. + if self.owners.is_draining() { + teardown_reason = Some(GoodbyeReason::Draining); + break Ok(()); + } + let room = self.rooms.get_or_create(session_id); + // Subscribe before admission; PeerRegistered carries a + // snapshot after admission, and queued deltas at or below + // that revision are ignored by the receiver. + let new_roster_rx = room.subscribe_roster(); + let reply = match self.directory.validate(community, &fenced).await { + Ok(()) => self.register_remote_peer( + Arc::clone(&room), + fenced, + from, + &pubkey, + protocol_version, + &mut registered, + ), + Err(e) => match FenceRejection::from_mesh_error(&e) { + Some(reason) => HuddleControlMsg::RegisterRejected { + pubkey: pubkey.clone(), + reason: RegisterRejection::Fenced(reason), + }, + None => break Err(e), + }, + }; + if let Err(e) = stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&reply)?, + }) + .await + { + break Err(e); + } + if matches!(reply, HuddleControlMsg::PeerRegistered { .. }) { + roster_rx = Some(new_roster_rx); + } + } + HuddleControlMsg::UnregisterPeer { pubkey } => { + if let Some(peer_id) = registered.remove(&pubkey) { + if let Some(room) = self.rooms.get(session_id) { + let peer_index = room.peers.get(&peer_id).map(|peer| peer.peer_index); + room.remove_peer(peer_id); + if let Some(peer_index) = peer_index { + room.broadcast_control( + serde_json::json!({ + "type": "left", + "pubkey": pubkey, + "peer_index": peer_index, + }) + .to_string(), + ); + } + } + } + } + HuddleControlMsg::RosterResync => { + let Some(room) = self.rooms.get(session_id) else { + break Ok(()); + }; + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&roster_snapshot_msg(&room))?, + }) + .await?; + } + // Owner→non-owner replies never arrive on the owner's accept + // side; a peer sending one is a protocol violation. + HuddleControlMsg::PeerRegistered { .. } + | HuddleControlMsg::RosterSnapshot { .. } + | HuddleControlMsg::RosterDelta { .. } + | HuddleControlMsg::RegisterRejected { .. } => { + break Err(MeshError::Transport( + "huddle-control owner received an owner→non-owner reply".into(), + )); + } + } + }; + + // Owner-initiated teardown: tell the non-owner pod why this owner is + // closing so it can rejoin against Redis. Best-effort — teardown + // proceeds even if the stream is already gone. Normal stream/client + // closes stay silent. + if let Some(reason) = teardown_reason { + let _ = stream + .send_frame(MeshStreamFrame::Goodbye { fenced, reason }) + .await; + } + + // Teardown: drop every peer this stream registered, regardless of how + // the loop ended. Dropping the peer drops its `audio_tx`, which ends the + // matching `spawn_remote_peer_sink` task. + if let Some(room) = self.rooms.get(session_id) { + for (pubkey, peer_id) in registered { + let peer_index = room.peers.get(&peer_id).map(|peer| peer.peer_index); + room.remove_peer(peer_id); + if let Some(peer_index) = peer_index { + room.broadcast_control( + serde_json::json!({ + "type": "left", + "pubkey": pubkey, + "peer_index": peer_index, + }) + .to_string(), + ); + } + } + } + result + } + + /// Admit one remote client into the owner's room and wire its fan-out back + /// to the registering pod as datagrams. Returns the reply to send. + fn register_remote_peer( + &self, + room: Arc, + fenced: FencedHeader, + from: RuntimeId, + pubkey: &str, + protocol_version: u8, + registered: &mut std::collections::HashMap, + ) -> HuddleControlMsg { + match room.add_peer(pubkey.to_string(), protocol_version) { + Ok((peer_id, peer_index, audio_rx, _peer_ctrl_rx)) => { + registered.insert(pubkey.to_string(), peer_id); + // The owner's Room fans out to this remote peer's `audio_tx`; + // the sink drains `audio_rx` and ships each frame as a datagram + // to the pod that hosts the client. + spawn_remote_peer_sink(Arc::clone(&self.transport), from, fenced, audio_rx); + let joined = serde_json::json!({ + "type": "joined", + "pubkey": pubkey, + "peer_index": peer_index, + "peers": [{"pubkey": pubkey, "peer_index": peer_index}], + }) + .to_string(); + room.broadcast_control(joined); + HuddleControlMsg::PeerRegistered { + pubkey: pubkey.to_string(), + peer_index, + roster: roster_snapshot(&room), + } + } + Err(reason) => HuddleControlMsg::RegisterRejected { + pubkey: pubkey.to_string(), + reason: admission_to_rejection(reason), + }, + } + } +} + +fn roster_snapshot(room: &Room) -> RosterSnapshot { + let snapshot = room.roster_snapshot(); + RosterSnapshot { + revision: snapshot.revision, + peers: snapshot.peers.into_iter().map(Into::into).collect(), + } +} + +fn roster_snapshot_msg(room: &Room) -> HuddleControlMsg { + let snapshot = roster_snapshot(room); + HuddleControlMsg::RosterSnapshot { + revision: snapshot.revision, + peers: snapshot.peers, + } +} + +fn roster_delta_msg(delta: RoomRosterDelta) -> HuddleControlMsg { + HuddleControlMsg::RosterDelta { + revision: delta.revision, + joined: delta.joined.map(Into::into), + left: delta.left.map(Into::into), + } +} + +/// Map a room admission failure to the wire rejection taxonomy. Kept 1:1 with +/// the single-pod WS error codes so a cross-pod join surfaces the same +/// client-facing error a same-pod join would. +fn admission_to_rejection(err: AdmissionError) -> RegisterRejection { + match err { + AdmissionError::Full => RegisterRejection::RoomFull, + AdmissionError::Ended => RegisterRejection::RoomEnded, + AdmissionError::VersionMismatch { pinned, requested } => { + RegisterRejection::VersionMismatch { pinned, requested } + } + } +} + +/// The `Goodbye` reason a non-owner sends when its client leaves the huddle +/// cleanly. Re-exported so the handler's dial path uses one spelling. +pub const HUDDLE_SESSION_ENDED: GoodbyeReason = GoodbyeReason::SessionEnded; + +// ── Non-owner-side HuddleControl dial path ─────────────────────────────────── +// +// A client connected here whose huddle is owned by another pod. We keep the +// client as an ordinary local WS peer (heartbeats, `joined`/`left`, delivery of +// the owner's fan-out) but there is NO local fan-out: the owner is the sole +// fan-out authority. Each client Opus frame is shipped to the owner as a +// datagram tagged with the OWNER-assigned peer index; the owner fans out to +// everyone (including this pod's co-located clients, which hear each other via +// the owner round-trip — `deliver_prefixed` skips a client's own index so it +// never hears itself). + +/// A registered cross-pod huddle session on the non-owner side. +/// +/// Holds everything needed to forward the local client's media to the owner and +/// to unregister cleanly on disconnect. Media delivery *back* to the client goes +/// through the ordinary local room via `MeshAudioRouter::on_media_datagram`, so +/// this handle owns only the outbound (client→owner) half plus teardown. +pub struct RemoteHuddleSession { + /// The owner-allocated peer index this client occupies in the owner's room. + /// Stamped on every media datagram so the owner attributes frames correctly. + peer_index: u8, + /// Latest complete authoritative owner roster. + roster: RosterSnapshot, + /// Fenced header for this session's owner epoch; every datagram carries it. + fenced: FencedHeader, + /// The pod that owns the huddle. + owner: RuntimeId, + /// Pubkey of the local client, for the closing `UnregisterPeer`. + pubkey: String, + /// Transport for datagrams and the control-stream teardown. + transport: Arc, + /// Per-datagram monotonic sequence for loss/reorder observability. + seq: u64, +} + +/// Why a non-owner pod is tearing down a client's cross-pod huddle session. +/// +/// Read off the owner's `HuddleControl` stream: the owner speaks its intent as +/// a [`MeshStreamFrame::Goodbye`] reason, or the stream simply ends. The +/// non-owner maps that cause to a client-facing outcome — every cause tears the +/// local client down and closes its WS so it can rejoin (against a fresh owner +/// or a fresh generation); the distinction is observability, not divergent +/// behaviour. There is no `Lost` wire variant: owner loss surfaces as +/// [`GoodbyeReason::StaleGeneration`] (the owner fenced itself out) or, if it +/// died mid-flight, as a bare stream close. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HuddleTeardownCause { + /// Owner sent `Goodbye(StaleGeneration)` — it observed a newer generation + /// and fenced itself out. From the client's view: the owner was lost; a + /// rejoin will resolve the new owner via Redis. + OwnerLost, + /// Owner sent `Goodbye(Draining)` — it is shutting down (SIGTERM). A rejoin + /// re-establishes against whichever pod takes the lease next. + OwnerDraining, + /// Owner sent `Goodbye(SessionEnded)` — the session ended normally + /// (e.g. the room emptied on the owner). An ordinary disconnect. + SessionEnded, + /// The stream closed or reset with no `Goodbye` — the owner pod died or the + /// link broke mid-flight. Treated like owner loss: rejoin to recover. + StreamClosed, +} + +impl HuddleTeardownCause { + fn from_goodbye(reason: GoodbyeReason) -> Self { + match reason { + GoodbyeReason::StaleGeneration => Self::OwnerLost, + GoodbyeReason::Draining => Self::OwnerDraining, + GoodbyeReason::SessionEnded => Self::SessionEnded, + } + } +} + +/// Forward authoritative roster control to one ingress client until teardown. +/// Revision gaps trigger a snapshot resync request before any newer delta is applied. +pub async fn read_owner_control( + stream: &mut MeshStream, + fenced: FencedHeader, + mut revision: u64, + ctrl_tx: &tokio::sync::mpsc::Sender, +) -> HuddleTeardownCause { + loop { + match stream.recv_frame().await { + Ok(Some(MeshStreamFrame::Goodbye { reason, .. })) => { + return HuddleTeardownCause::from_goodbye(reason); + } + Ok(Some(MeshStreamFrame::Data { payload, .. })) => match decode_control(&payload) { + Ok(HuddleControlMsg::RosterSnapshot { + revision: next, + peers, + }) => { + revision = next; + let json = serde_json::json!({ + "type": "roster", "revision": revision, + "peers": peers.into_iter().map(|p| serde_json::json!({ + "pubkey": p.pubkey, "peer_index": p.peer_index, + })).collect::>() + }) + .to_string(); + if ctrl_tx + .send(axum::extract::ws::Message::Text(json.into())) + .await + .is_err() + { + return HuddleTeardownCause::SessionEnded; + } + } + Ok(HuddleControlMsg::RosterDelta { + revision: next, + joined, + left, + }) if next == revision.wrapping_add(1) => { + revision = next; + let json = if let Some(peer) = joined { + serde_json::json!({ + "type": "joined", "revision": revision, + "pubkey": peer.pubkey, "peer_index": peer.peer_index, + "peers": [{"pubkey": peer.pubkey, "peer_index": peer.peer_index}], + }) + } else if let Some(peer) = left { + serde_json::json!({ + "type": "left", "revision": revision, + "pubkey": peer.pubkey, "peer_index": peer.peer_index, + }) + } else { + continue; + }; + if ctrl_tx + .send(axum::extract::ws::Message::Text(json.to_string().into())) + .await + .is_err() + { + return HuddleTeardownCause::SessionEnded; + } + } + Ok(HuddleControlMsg::RosterDelta { revision: next, .. }) if next <= revision => {} + Ok(HuddleControlMsg::RosterDelta { .. }) => { + let payload = match encode_control(&HuddleControlMsg::RosterResync) { + Ok(payload) => payload, + Err(_) => return HuddleTeardownCause::StreamClosed, + }; + if stream + .send_frame(MeshStreamFrame::Data { fenced, payload }) + .await + .is_err() + { + return HuddleTeardownCause::StreamClosed; + } + } + Ok(_) => {} + Err(e) => debug!(owner_stream_error = %e, "invalid huddle owner control"), + }, + Ok(Some(_)) => continue, + Ok(None) => return HuddleTeardownCause::StreamClosed, + Err(e) => { + debug!(owner_stream_error = %e, "huddle owner stream ended abnormally"); + return HuddleTeardownCause::StreamClosed; + } + } + } +} + +/// Read the owner's `HuddleControl` stream until it signals teardown. +/// +/// The non-owner side's stream carries only owner→client control today +/// (`PeerRegistered`/`RegisterRejected` are consumed during the dial). Any +/// further `Data`/`Gossip` frame is non-terminal and skipped — this is the +/// forward-compatible seam for owner→client roster deltas, which must not be +/// mistaken for teardown. The loop returns exactly once, on the first terminal +/// signal: a [`MeshStreamFrame::Goodbye`], or a clean close / transport error +/// (both [`HuddleTeardownCause::StreamClosed`]). +pub async fn read_teardown_cause(stream: &mut MeshStream) -> HuddleTeardownCause { + loop { + match stream.recv_frame().await { + Ok(Some(MeshStreamFrame::Goodbye { reason, .. })) => { + return HuddleTeardownCause::from_goodbye(reason); + } + // Non-terminal owner→client traffic (future roster deltas): ignore + // and keep reading. A stray Hello here would be a protocol error, + // but the transport already validated the opening Hello, so treat + // any non-Goodbye frame as non-terminal rather than tearing down. + Ok(Some(_)) => continue, + // Clean close (None) or transport error: the owner is gone. + Ok(None) => return HuddleTeardownCause::StreamClosed, + Err(e) => { + debug!(owner_stream_error = %e, "huddle owner stream ended abnormally"); + return HuddleTeardownCause::StreamClosed; + } + } + } +} + +/// Why a cross-pod join could not complete on the non-owner side. +#[derive(Debug)] +pub enum DialError { + /// The owner refused the registration; surfaced to the client as the same + /// WS error a same-pod join would produce, never a silent media drop. + Rejected(RegisterRejection), + /// Transport / protocol failure opening or serving the control stream. + Mesh(MeshError), +} + +impl From for DialError { + fn from(e: MeshError) -> Self { + DialError::Mesh(e) + } +} + +/// Open a `HuddleControl` stream to the owner and register the local client. +/// +/// On success the owner has admitted the client as a remote peer and returned +/// its owner-assigned index; the returned [`RemoteHuddleSession`] forwards media +/// and unregisters on drop. On [`DialError::Rejected`] the caller surfaces the +/// owner's admission failure to the client unchanged. +pub async fn dial_remote_owner( + transport: Arc, + local_runtime_id: RuntimeId, + owner: RuntimeId, + fenced: FencedHeader, + community_id: CommunityId, + pubkey: String, + protocol_version: u8, +) -> Result<(RemoteHuddleSession, MeshStream), DialError> { + let hello = StreamHello { + sender: local_runtime_id, + role: StreamRole::Session { + fenced, + profile: Profile::HuddleControl, + }, + }; + // `open_session_stream` sends the Hello before returning. + let mut stream = transport.open_session_stream(owner, hello).await?; + + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterPeer { + community_id: *community_id.as_uuid(), + pubkey: pubkey.clone(), + protocol_version, + })?, + }) + .await?; + + match stream.recv_frame().await? { + Some(MeshStreamFrame::Data { payload, .. }) => match decode_control(&payload)? { + HuddleControlMsg::PeerRegistered { + peer_index, roster, .. + } => Ok(( + RemoteHuddleSession { + peer_index, + roster, + fenced, + owner, + pubkey, + transport, + seq: 0, + }, + stream, + )), + HuddleControlMsg::RegisterRejected { reason, .. } => Err(DialError::Rejected(reason)), + other => Err(DialError::Mesh(MeshError::Transport(format!( + "expected PeerRegistered/RegisterRejected, got {other:?}" + )))), + }, + Some(MeshStreamFrame::Goodbye { .. }) | None => Err(DialError::Mesh(MeshError::Transport( + "owner closed HuddleControl stream before replying".into(), + ))), + Some(other) => Err(DialError::Mesh(MeshError::Transport(format!( + "unexpected HuddleControl frame from owner: {other:?}" + )))), + } +} + +/// The `StreamHello.sender` for a dialed session: the fenced header carries the +/// owner's identity, but the *sender* is this pod. The owner validates +/// `hello.sender == authenticated peer`, so it must be our own runtime id — the +/// handler threads `local_runtime_id` in explicitly. +impl RemoteHuddleSession { + /// The owner-assigned index this client occupies in the owner's room. + pub fn peer_index(&self) -> u8 { + self.peer_index + } + + /// Complete authoritative roster returned atomically with registration. + pub fn roster(&self) -> &RosterSnapshot { + &self.roster + } + + /// The session fence — used by the reader task to author the closing + /// `UnregisterPeer` / `Goodbye` on the owner's control stream. + pub fn fenced(&self) -> FencedHeader { + self.fenced + } + + /// The local client's pubkey — used by the reader task's `UnregisterPeer`. + pub fn pubkey(&self) -> &str { + &self.pubkey + } + + /// Forward one client Opus frame to the owner as a media datagram, tagged + /// with the owner-assigned index. Drop-on-error: realtime audio never blocks + /// on a slow or gone link (the same discipline as local fan-out). + pub fn forward_media(&mut self, client_frame: &[u8]) { + let dgram = media_datagram(self.peer_index, self.fenced, self.seq, client_frame); + self.seq = self.seq.wrapping_add(1); + if let Err(e) = self.transport.send_datagram(self.owner, dgram) { + debug!(owner = %self.owner, "huddle media datagram to owner failed: {e}"); + } + } +} + +/// Unregister the client from the owner and close the control stream cleanly. +/// +/// Called on a *local-client-initiated* disconnect (the reader task's cancel +/// branch), so the owner drops the remote peer and stops fanning media back. +/// Best-effort: teardown never blocks connection cleanup, and a `Goodbye` +/// already received from the owner makes this a no-op the owner ignores. +pub async fn send_clean_close(stream: &mut MeshStream, fenced: FencedHeader, pubkey: &str) { + if let Ok(payload) = encode_control(&HuddleControlMsg::UnregisterPeer { + pubkey: pubkey.to_string(), + }) { + let _ = stream + .send_frame(MeshStreamFrame::Data { fenced, payload }) + .await; + } + let _ = stream + .send_frame(MeshStreamFrame::Goodbye { + fenced, + reason: HUDDLE_SESSION_ENDED, + }) + .await; + let _ = stream.finish(); +} + +/// Build the media datagram a non-owner ships to the owner for one client +/// frame: `[owner_peer_index][client frame]`, stamped with the session fence +/// and sequence. Pure so the framing is unit-testable without a live transport +/// or stream. +fn media_datagram( + peer_index: u8, + fenced: FencedHeader, + seq: u64, + client_frame: &[u8], +) -> MeshDatagram { + let mut payload = Vec::with_capacity(1 + client_frame.len()); + payload.push(peer_index); + payload.extend_from_slice(client_frame); + MeshDatagram { + fenced, + seq, + payload, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + fn rt(b: u8) -> RuntimeId { + RuntimeId([b; 32]) + } + + fn community() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(0xC0FFEE)) + } + + /// Scripted directory: `owner_of` returns a queued lookup, `acquire` + /// returns a queued outcome, `validate` returns a queued result. Records + /// call counts so ordering can be asserted. + #[derive(Default)] + struct FakeDir { + owner: Mutex>, + acquire: Mutex>, + validate_fails: Mutex, + acquire_calls: Mutex, + validate_calls: Mutex, + // Renewer scripting: each `renew` pops the next outcome; an empty queue + // yields `Renewed` (lease holds). `release` returns the scripted value. + renew_outcomes: Mutex>, + release_outcome: Mutex>, + renew_calls: Mutex, + release_calls: Mutex, + } + + impl FakeDir { + fn owned_by(o: Ownership) -> Self { + let d = Self::default(); + *d.owner.lock().unwrap() = Some(o); + d + } + fn unowned_then_acquire(a: AcquireOutcome) -> Self { + let d = Self::default(); + *d.acquire.lock().unwrap() = Some(a); + d + } + /// Renewer-lane double: `renew` yields the scripted outcomes in order + /// (then holds), `release` yields `release`. + fn with_renew_script( + renews: impl IntoIterator, + release: HuddleReleaseOutcome, + ) -> Self { + let d = Self::default(); + *d.renew_outcomes.lock().unwrap() = renews.into_iter().collect(); + *d.release_outcome.lock().unwrap() = Some(release); + d + } + } + + #[async_trait::async_trait] + impl HuddleDirectory for FakeDir { + async fn owner_of( + &self, + _c: CommunityId, + _s: Uuid, + ) -> Result, MeshError> { + Ok(*self.owner.lock().unwrap()) + } + async fn acquire( + &self, + _c: CommunityId, + _s: Uuid, + _owner: RuntimeId, + ) -> Result { + *self.acquire_calls.lock().unwrap() += 1; + Ok(self + .acquire + .lock() + .unwrap() + .clone() + .expect("acquire not scripted")) + } + async fn validate(&self, _c: CommunityId, _f: &FencedHeader) -> Result<(), MeshError> { + *self.validate_calls.lock().unwrap() += 1; + if *self.validate_fails.lock().unwrap() { + Err(MeshError::OwnerMismatch { + session_id: Uuid::nil(), + generation: 0, + frame_owner_runtime_id: rt(0), + current_owner_runtime_id: rt(1), + }) + } else { + Ok(()) + } + } + async fn renew(&self, _lease: &HuddleLease) -> Result { + *self.renew_calls.lock().unwrap() += 1; + Ok(self + .renew_outcomes + .lock() + .unwrap() + .pop_front() + .unwrap_or(HuddleRenewOutcome::Renewed(test_lease()))) + } + async fn release(&self, _lease: &HuddleLease) -> Result { + *self.release_calls.lock().unwrap() += 1; + Ok(self + .release_outcome + .lock() + .unwrap() + .clone() + .unwrap_or(HuddleReleaseOutcome::Released)) + } + } + + /// A `HuddleLease` for renewer tests: the inner `SessionLease` is opaque to + /// the huddle lane, so any well-formed fenced tuple works. + fn test_lease() -> HuddleLease { + lease_for(Uuid::from_u128(0xFEED), 7) + } + + /// A `HuddleLease` for a specific session and generation — the registry + /// tests key on `session_id` and fence `release` on `generation`, so they + /// need to vary both. + fn lease_for(session_id: Uuid, generation: u64) -> HuddleLease { + HuddleLease(SessionLease { + community_id: community(), + session_id, + owner_runtime_id: rt(1), + generation, + profile: HUDDLE_CONTROL_PROFILE, + }) + } + + #[tokio::test] + async fn unowned_huddle_is_acquired_as_local_owner() { + let dir = + FakeDir::unowned_then_acquire(AcquireOutcome::Acquired(HuddleLease(SessionLease { + community_id: community(), + session_id: Uuid::from_u128(0xFEED), + owner_runtime_id: rt(1), + generation: 7, + profile: HUDDLE_CONTROL_PROFILE, + }))); + let out = resolve_join(&dir, community(), Uuid::new_v4(), rt(1)) + .await + .unwrap(); + assert_eq!(out.outcome, JoinOutcome::LocalOwner { generation: 7 }); + // Freshly acquired → the real lease is surfaced for the renewer. + assert_eq!(out.acquired.as_ref().map(HuddleLease::generation), Some(7)); + assert_eq!(*dir.acquire_calls.lock().unwrap(), 1); + // No fence validation on the local-owner path — we ARE the lease. + assert_eq!(*dir.validate_calls.lock().unwrap(), 0); + } + + #[tokio::test] + async fn huddle_owned_by_us_is_local_owner_without_acquire() { + let dir = FakeDir::owned_by(Ownership { + owner_runtime_id: rt(1), + generation: 3, + }); + let out = resolve_join(&dir, community(), Uuid::new_v4(), rt(1)) + .await + .unwrap(); + assert_eq!(out.outcome, JoinOutcome::LocalOwner { generation: 3 }); + // Steady-state owner reuses the registry renewer — no fresh lease. + assert!(out.acquired.is_none()); + // Live lease found → no acquire attempt. + assert_eq!(*dir.acquire_calls.lock().unwrap(), 0); + } + + #[tokio::test] + async fn huddle_owned_by_peer_is_remote_owner_and_fence_validated() { + let dir = FakeDir::owned_by(Ownership { + owner_runtime_id: rt(2), + generation: 9, + }); + let out = resolve_join(&dir, community(), Uuid::new_v4(), rt(1)) + .await + .unwrap(); + assert_eq!( + out.outcome, + JoinOutcome::RemoteOwner { + owner_runtime_id: rt(2), + generation: 9, + } + ); + assert!(out.acquired.is_none()); + // The remote-owner path validates the fence before routing. + assert_eq!(*dir.validate_calls.lock().unwrap(), 1); + } + + #[tokio::test] + async fn lost_acquire_race_routes_to_winner_as_remote_owner() { + let dir = FakeDir::unowned_then_acquire(AcquireOutcome::Held(Ownership { + owner_runtime_id: rt(2), + generation: 4, + })); + let out = resolve_join(&dir, community(), Uuid::new_v4(), rt(1)) + .await + .unwrap(); + assert_eq!( + out.outcome, + JoinOutcome::RemoteOwner { + owner_runtime_id: rt(2), + generation: 4, + } + ); + assert!(out.acquired.is_none()); + assert_eq!(*dir.acquire_calls.lock().unwrap(), 1); + assert_eq!(*dir.validate_calls.lock().unwrap(), 1); + } + + #[tokio::test] + async fn remote_owner_fence_rejection_propagates() { + let dir = FakeDir::owned_by(Ownership { + owner_runtime_id: rt(2), + generation: 9, + }); + *dir.validate_fails.lock().unwrap() = true; + let err = resolve_join(&dir, community(), Uuid::new_v4(), rt(1)) + .await + .unwrap_err(); + assert!(matches!(err, MeshError::OwnerMismatch { .. })); + } + + #[test] + fn control_msg_roundtrips() { + for msg in [ + HuddleControlMsg::RegisterPeer { + community_id: *community().as_uuid(), + pubkey: "abc123".into(), + protocol_version: 2, + }, + HuddleControlMsg::PeerRegistered { + pubkey: "abc123".into(), + peer_index: 42, + roster: RosterSnapshot { + revision: 1, + peers: vec![RosterEntry { + pubkey: "abc123".into(), + peer_index: 42, + }], + }, + }, + HuddleControlMsg::RosterDelta { + revision: 2, + joined: None, + left: Some(RosterEntry { + pubkey: "abc123".into(), + peer_index: 42, + }), + }, + HuddleControlMsg::RosterResync, + HuddleControlMsg::RegisterRejected { + pubkey: "abc123".into(), + reason: RegisterRejection::VersionMismatch { + pinned: 2, + requested: 1, + }, + }, + HuddleControlMsg::RegisterRejected { + pubkey: "abc123".into(), + reason: RegisterRejection::Fenced(FenceRejection::StaleGeneration), + }, + HuddleControlMsg::UnregisterPeer { + pubkey: "abc123".into(), + }, + ] { + let bytes = encode_control(&msg).unwrap(); + assert_eq!(decode_control(&bytes).unwrap(), msg); + } + } + + // ── In-memory MeshStream pair for handshake round-trip tests ───────────── + // + // A channel-backed `StreamSendHalf`/`StreamRecvHalf` pair drives + // `accept_inbound` end-to-end without iroh: what the owner side sends, the + // client side receives, and vice versa. Uses only the public + // `MeshStream::new` seam plus the public half traits. + use buzz_relay_mesh::{BoxFuture, StreamRecvHalf, StreamSendHalf}; + use tokio::sync::mpsc as tmpsc; + + struct ChanSend(tmpsc::UnboundedSender); + struct ChanRecv(tmpsc::UnboundedReceiver); + + impl StreamSendHalf for ChanSend { + fn send_frame(&mut self, frame: MeshStreamFrame) -> BoxFuture<'_, Result<(), MeshError>> { + let r = self + .0 + .send(frame) + .map_err(|_| MeshError::Transport("peer closed".into())); + Box::pin(async move { r }) + } + fn finish(&mut self) -> Result<(), MeshError> { + Ok(()) + } + } + + impl StreamRecvHalf for ChanRecv { + fn recv_frame(&mut self) -> BoxFuture<'_, Result, MeshError>> { + Box::pin(async move { Ok(self.0.recv().await) }) + } + } + + /// A connected `(owner_side, client_side)` `MeshStream` pair. + fn stream_pair() -> (MeshStream, MeshStream) { + let (a_tx, a_rx) = tmpsc::unbounded_channel(); + let (b_tx, b_rx) = tmpsc::unbounded_channel(); + // owner sends on a_tx (client reads a_rx); client sends on b_tx (owner + // reads b_rx). + let owner = MeshStream::new(Box::new(ChanSend(a_tx)), Box::new(ChanRecv(b_rx))); + let client = MeshStream::new(Box::new(ChanSend(b_tx)), Box::new(ChanRecv(a_rx))); + (owner, client) + } + + #[tokio::test] + async fn roster_revision_gap_requests_resync_before_forwarding_new_state() { + let session_id = Uuid::new_v4(); + let fenced = fenced_owned_by(rt(1), session_id); + let (mut owner, mut client) = stream_pair(); + let (ctrl_tx, mut ctrl_rx) = tokio::sync::mpsc::channel(4); + let reader = + tokio::spawn(async move { read_owner_control(&mut client, fenced, 1, &ctrl_tx).await }); + + owner + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RosterDelta { + revision: 3, + joined: Some(RosterEntry { + pubkey: "bob".into(), + peer_index: 7, + }), + left: None, + }) + .unwrap(), + }) + .await + .unwrap(); + + let request = owner.recv_frame().await.unwrap().unwrap(); + let MeshStreamFrame::Data { payload, .. } = request else { + panic!("expected roster resync request"); + }; + assert_eq!( + decode_control(&payload).unwrap(), + HuddleControlMsg::RosterResync + ); + assert!( + ctrl_rx.try_recv().is_err(), + "gapped delta was not forwarded" + ); + + owner + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RosterSnapshot { + revision: 3, + peers: vec![RosterEntry { + pubkey: "bob".into(), + peer_index: 7, + }], + }) + .unwrap(), + }) + .await + .unwrap(); + let message = ctrl_rx.recv().await.expect("replacement roster forwarded"); + let axum::extract::ws::Message::Text(json) = message else { + panic!("expected roster JSON"); + }; + let json: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(json["type"], "roster"); + assert_eq!(json["revision"], 3); + + drop(owner); + assert_eq!(reader.await.unwrap(), HuddleTeardownCause::StreamClosed); + } + + /// Transport whose only exercised method is a no-op `send_datagram` (the + /// remote-peer sink fires into it). `open_session_stream`/`set_inbound` are + /// not reached on the accept path. + struct NullTransport; + impl RelayPeerTransport for NullTransport { + fn send_datagram(&self, _to: RuntimeId, _d: MeshDatagram) -> Result<(), MeshError> { + Ok(()) + } + fn open_session_stream( + &self, + _to: RuntimeId, + _hello: StreamHello, + ) -> BoxFuture<'_, Result> { + Box::pin(async { Err(MeshError::Transport("unused".into())) }) + } + fn set_inbound(&self, _handler: Box) {} + } + + fn fenced_owned_by(owner: RuntimeId, session_id: Uuid) -> FencedHeader { + FencedHeader { + session_id, + generation: 7, + owner_runtime_id: owner, + } + } + + fn huddle_hello(sender: RuntimeId, fenced: FencedHeader) -> StreamHello { + StreamHello { + sender, + role: StreamRole::Session { + fenced, + profile: Profile::HuddleControl, + }, + } + } + + /// Full accept-side handshake: a structural `Hello`, then a + /// community-bearing `RegisterPeer` whose fence passes, yields + /// `PeerRegistered`. Exercises the public `MeshStream::new` seam and the + /// validate-before-admit path end-to-end. + #[tokio::test] + async fn register_peer_handshake_admits_on_valid_fence() { + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + + let acceptor = HuddleControlAcceptor::new( + Arc::new(AudioRoomManager::new()), + Arc::new(NullTransport) as Arc, + Arc::new(FakeDir::default()), // validate() succeeds by default + owner_rt, + Arc::new(HuddleOwnerRegistry::new()), // no owner lease → recv-only + ); + + let (owner_stream, mut client) = stream_pair(); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + // Client registers, carrying its community as the wire UUID. + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterPeer { + community_id: *community().as_uuid(), + pubkey: "client-a".into(), + protocol_version: 2, + }) + .unwrap(), + }) + .await + .unwrap(); + + let reply = match client.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => decode_control(&payload).unwrap(), + other => panic!("expected Data reply, got {other:?}"), + }; + assert!( + matches!(reply, HuddleControlMsg::PeerRegistered { ref pubkey, .. } if pubkey == "client-a"), + "expected PeerRegistered for client-a, got {reply:?}" + ); + + // Closing the client stream ends the serve loop cleanly. + client.finish().unwrap(); + drop(client); + served.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn abnormal_control_stream_close_fans_out_remote_leave() { + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + let rooms = Arc::new(AudioRoomManager::new()); + let room = rooms.get_or_create(session_id); + let (_local_id, _local_index, _audio_rx, mut local_ctrl_rx) = + room.add_peer("owner-local".into(), 2).unwrap(); + // Discard the local peer's own roster delta; this assertion targets the + // websocket-compatible control fanout below. + + let acceptor = HuddleControlAcceptor::new( + Arc::clone(&rooms), + Arc::new(NullTransport) as Arc, + Arc::new(FakeDir::default()), + owner_rt, + Arc::new(HuddleOwnerRegistry::new()), + ); + let (owner_stream, mut client) = stream_pair(); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterPeer { + community_id: *community().as_uuid(), + pubkey: "remote".into(), + protocol_version: 2, + }) + .unwrap(), + }) + .await + .unwrap(); + let _registered = client.recv_frame().await.unwrap().unwrap(); + let joined = local_ctrl_rx.recv().await.expect("remote join fanout"); + let super::super::room::PeerCtrl::Json(joined) = joined else { + panic!("expected joined JSON"); + }; + let joined: serde_json::Value = serde_json::from_str(&joined).unwrap(); + assert_eq!(joined["type"], "joined"); + assert_eq!(joined["pubkey"], "remote"); + let remote_index = joined["peer_index"].as_u64().unwrap(); + + drop(client); + served.await.unwrap().unwrap(); + let left = local_ctrl_rx + .recv() + .await + .expect("abnormal-close leave fanout"); + let super::super::room::PeerCtrl::Json(left) = left else { + panic!("expected left JSON"); + }; + let left: serde_json::Value = serde_json::from_str(&left).unwrap(); + assert_eq!(left["type"], "left"); + assert_eq!(left["pubkey"], "remote"); + assert_eq!(left["peer_index"], remote_index); + assert_eq!(room.peer_pubkeys(), vec![("owner-local".into(), 0)]); + } + + /// A `RegisterPeer` whose fence is rejected (wrong community keys a lease + /// Redis never wrote) yields a `RegisterRejected(Fenced(..))` reply — no + /// peer admitted — and the stream stays alive for the client to close. + #[tokio::test] + async fn register_peer_handshake_rejects_on_fence_failure() { + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + + let dir = FakeDir::default(); + *dir.validate_fails.lock().unwrap() = true; + let acceptor = HuddleControlAcceptor::new( + Arc::new(AudioRoomManager::new()), + Arc::new(NullTransport) as Arc, + Arc::new(dir), + owner_rt, + Arc::new(HuddleOwnerRegistry::new()), // no owner lease → recv-only + ); + + let (owner_stream, mut client) = stream_pair(); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterPeer { + community_id: *community().as_uuid(), + pubkey: "client-a".into(), + protocol_version: 2, + }) + .unwrap(), + }) + .await + .unwrap(); + + let reply = match client.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => decode_control(&payload).unwrap(), + other => panic!("expected Data reply, got {other:?}"), + }; + assert!( + matches!( + reply, + HuddleControlMsg::RegisterRejected { + reason: RegisterRejection::Fenced(_), + .. + } + ), + "expected fence rejection, got {reply:?}" + ); + + drop(client); + served.await.unwrap().unwrap(); + } + + // --- Huddle-lease renewer: owner-loss observability --------------------- + // + // These mirror the reliable lane's renewer tests + // (`crate::tunnel::reliable::observable_renewer_*`), driven off the + // `HuddleDirectory` seam with a scripted `FakeDir` so no Redis is needed. + + #[tokio::test] + async fn observable_renewer_signals_loss_when_lease_disappears() { + // First renew observes the lease gone → owner-loss. + let dir = Arc::new(FakeDir::with_renew_script( + [HuddleRenewOutcome::Lost], + HuddleReleaseOutcome::Released, + )); + let cancel = CancellationToken::new(); + let renewer = spawn_huddle_renewer_with_interval( + Arc::clone(&dir) as Arc, + test_lease(), + cancel, + Duration::from_millis(5), + ); + + tokio::time::timeout(Duration::from_secs(2), renewer.lost.cancelled()) + .await + .expect("lost token is cancelled after ownership loss"); + renewer.task.await.unwrap(); + assert_eq!(*dir.renew_calls.lock().unwrap(), 1); + // Loss still runs the release attempt on the way out. + assert_eq!(*dir.release_calls.lock().unwrap(), 1); + } + + #[tokio::test] + async fn observable_renewer_normal_cancel_does_not_signal_loss() { + // Empty renew script → `Renewed` forever; caller cancels cleanly. + let dir = Arc::new(FakeDir::with_renew_script( + std::iter::empty(), + HuddleReleaseOutcome::Released, + )); + let cancel = CancellationToken::new(); + let renewer = spawn_huddle_renewer_with_interval( + Arc::clone(&dir) as Arc, + test_lease(), + cancel.clone(), + Duration::from_millis(5), + ); + cancel.cancel(); + renewer.task.await.unwrap(); + assert!( + !renewer.lost.is_cancelled(), + "caller-initiated shutdown is not ownership loss" + ); + // Clean release ran and did not trip the loss signal. + assert_eq!(*dir.release_calls.lock().unwrap(), 1); + } + + // --- Huddle owner registry: install / reuse / fenced release ------------ + + /// Poll `dir.release_calls` until it reaches `want` or the deadline: the + /// registry owns the renewer task (no JoinHandle exposed), so a released + /// lease is observed through the directory double it releases against. + async fn await_release_calls(dir: &FakeDir, want: u32) { + tokio::time::timeout(Duration::from_secs(2), async { + while *dir.release_calls.lock().unwrap() < want { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "expected {want} release(s), saw {}", + *dir.release_calls.lock().unwrap() + ) + }); + } + + /// A second `attach` on a live room reuses the installed renewer and hands + /// back its `lost` — and releases the extra lease so no second renewer + /// leaks. The reused signal is the same token the first attach returned. + #[tokio::test] + async fn registry_double_attach_reuses_renewer_and_releases_extra_lease() { + let dir = Arc::new(FakeDir::default()); // renew holds, release => Released + let registry = HuddleOwnerRegistry::new(); + let session = Uuid::new_v4(); + + let first = registry.attach( + session, + Arc::clone(&dir) as Arc, + lease_for(session, 7), + ); + let second = registry.attach( + session, + Arc::clone(&dir) as Arc, + lease_for(session, 7), + ); + + // Same room → same loss signal; the second attach did not replace it. + assert!(!first.is_cancelled()); + first.cancel(); + assert!( + second.is_cancelled(), + "reused entry shares the first renewer's lost token" + ); + + // Exactly one extra lease was released (the throwaway renewer); the + // installed renewer is still live (only its caller-cancel releases it). + await_release_calls(&dir, 1).await; + assert!( + registry.lost_for(session).is_some(), + "installed entry stays live" + ); + } + + /// `release` fenced on generation: a stale room-empty (older generation + /// than the live entry, e.g. a re-acquire landed in the gap) is a no-op — + /// it neither drops the entry nor cancels the live renewer. + #[tokio::test] + async fn registry_release_is_generation_fenced() { + let dir = Arc::new(FakeDir::default()); + let registry = HuddleOwnerRegistry::new(); + let session = Uuid::new_v4(); + + // Live entry owns generation 9 (a re-acquire installed the new epoch). + let lost = registry.attach( + session, + Arc::clone(&dir) as Arc, + lease_for(session, 9), + ); + + // A stale leaver releases generation 8 → no-op against the newer epoch. + registry.release(session, 8); + assert!( + registry.lost_for(session).is_some(), + "stale release must not drop a newer entry" + ); + assert!( + !lost.is_cancelled(), + "stale release must not cancel the live renewer" + ); + assert_eq!( + *dir.release_calls.lock().unwrap(), + 0, + "stale release runs no directory release" + ); + + // The matching generation releases cleanly: entry gone, renewer's + // caller-cancel drives the directory release, and loss stays silent. + registry.release(session, 9); + assert!( + registry.lost_for(session).is_none(), + "matching release drops the entry" + ); + await_release_calls(&dir, 1).await; + assert!(!lost.is_cancelled(), "clean release is not owner-loss"); + } + + /// The room-empty release path: attach installs, `release` on the same + /// generation drops the entry and releases the lease. Mirrors the + /// `handler.rs` `room_emptied` call with a matching generation. + #[tokio::test] + async fn registry_room_empty_release_drops_entry_and_releases_lease() { + let dir = Arc::new(FakeDir::default()); + let registry = HuddleOwnerRegistry::new(); + let session = Uuid::new_v4(); + + let lost = registry.attach( + session, + Arc::clone(&dir) as Arc, + lease_for(session, 3), + ); + assert!(registry.lost_for(session).is_some()); + + registry.release(session, 3); + assert!( + registry.lost_for(session).is_none(), + "room-empty release clears the entry" + ); + await_release_calls(&dir, 1).await; + assert!( + !lost.is_cancelled(), + "room-empty is a clean release, not owner-loss" + ); + } + + /// `drain` is generation-fenced like `release`, but unlike room-empty it + /// also cancels the drain signal so local owner peers and remote control + /// streams can rejoin with an explicit draining cause before the renewer + /// releases the lease. + #[tokio::test] + async fn registry_drain_signals_rejoin_and_releases_lease() { + let dir = Arc::new(FakeDir::default()); + let registry = HuddleOwnerRegistry::new(); + let session = Uuid::new_v4(); + + let lost = registry.attach( + session, + Arc::clone(&dir) as Arc, + lease_for(session, 4), + ); + let draining = registry.drain_for(session).expect("drain signal installed"); + + assert!(registry.drain(session, 4), "matching generation drains"); + assert!( + registry.lost_for(session).is_none(), + "drain drops the owner entry" + ); + assert!(draining.is_cancelled(), "drain fans out to owners/streams"); + await_release_calls(&dir, 1).await; + assert!(!lost.is_cancelled(), "drain is not fenced owner-loss"); + } + + #[test] + fn drain_all_permanently_fences_new_owner_admission() { + let registry = HuddleOwnerRegistry::new(); + assert!(!registry.is_draining()); + assert_eq!(registry.drain_all(), 0); + assert!(registry.is_draining()); + } + + #[tokio::test] + async fn attach_after_drain_releases_new_lease_and_returns_cancelled_signal() { + let dir = Arc::new(FakeDir::default()); + let registry = HuddleOwnerRegistry::new(); + let session = Uuid::new_v4(); + registry.drain_all(); + + let signals = registry.attach_signals( + session, + Arc::clone(&dir) as Arc, + lease_for(session, 11), + ); + assert!(signals.draining.is_cancelled()); + assert!(registry.lost_for(session).is_none()); + await_release_calls(&dir, 1).await; + } + + /// A stale drain racing a fresh re-acquire must not cancel the new epoch's + /// drain signal or release its renewer. + #[tokio::test] + async fn registry_drain_is_generation_fenced() { + let dir = Arc::new(FakeDir::default()); + let registry = HuddleOwnerRegistry::new(); + let session = Uuid::new_v4(); + + registry.attach( + session, + Arc::clone(&dir) as Arc, + lease_for(session, 9), + ); + let draining = registry.drain_for(session).expect("drain signal installed"); + + assert!(!registry.drain(session, 8), "stale generation is no-op"); + assert!(registry.lost_for(session).is_some()); + assert!(!draining.is_cancelled()); + assert_eq!(*dir.release_calls.lock().unwrap(), 0); + } + + // --- resolve_join_owner_ready: the LocalOwner reuse race gate ------------ + + /// The exact interleaving the review flagged: the CAS winner has resolved + /// (Redis names this pod owner) but has not yet installed its registry entry + /// when a second joiner resolves `LocalOwner`. `resolve_join_owner_ready` + /// must NOT return the bare snapshot — it re-resolves until the winner's + /// entry appears, then returns reuse. Never admits an ownerless owner peer. + #[tokio::test] + async fn owner_ready_waits_for_winner_install_then_reuses() { + // Redis says this pod (rt(1)) owns generation 5 — the steady-state + // reuse arm (acquired = None), winner not yet attached. + let dir = Arc::new(FakeDir::owned_by(Ownership { + owner_runtime_id: rt(1), + generation: 5, + })); + let registry = Arc::new(HuddleOwnerRegistry::new()); + let session = Uuid::new_v4(); + + // The winner installs its entry after a couple of retry intervals. + let installer = Arc::clone(®istry); + let dir_for_install = Arc::clone(&dir); + let install = tokio::spawn(async move { + tokio::time::sleep(OWNER_READY_RETRY_INTERVAL * 3).await; + installer.attach( + session, + dir_for_install as Arc, + lease_for(session, 5), + ); + }); + + let resolved = resolve_join_owner_ready(&*dir, community(), session, rt(1), ®istry) + .await + .unwrap(); + + install.await.unwrap(); + // Reuse: LocalOwner with no fresh lease (the winner holds it), and the + // live entry now exists so the caller gets a real loss watcher. + assert_eq!(resolved.outcome, JoinOutcome::LocalOwner { generation: 5 }); + assert!(resolved.acquired.is_none(), "reuse arm mints no new lease"); + assert!( + registry.lost_for(session).is_some(), + "returned only once a live entry gated reuse" + ); + } + + /// If the room emptied and released underneath the racing joiner, the + /// re-resolve wins a fresh CAS instead of adopting the torn-down lease: + /// `owner_of` now reports unowned, `acquire` grants a new generation, and + /// the result carries the real lease (acquired = Some) for the caller to + /// install. + #[tokio::test] + async fn owner_ready_reacquires_when_room_released_underneath() { + // Starts LocalOwner/no-entry (the ambiguous window)... + let dir = Arc::new(FakeDir::owned_by(Ownership { + owner_runtime_id: rt(1), + generation: 5, + })); + // ...then the room empties + releases: owner_of goes unowned and our + // acquire wins a fresh generation. + let dir_flip = Arc::clone(&dir); + let flip = tokio::spawn(async move { + tokio::time::sleep(OWNER_READY_RETRY_INTERVAL * 2).await; + *dir_flip.owner.lock().unwrap() = None; + *dir_flip.acquire.lock().unwrap() = + Some(AcquireOutcome::Acquired(lease_for(Uuid::nil(), 6))); + }); + let registry = HuddleOwnerRegistry::new(); // stays empty → forces retry + + let resolved = + resolve_join_owner_ready(&*dir, community(), Uuid::new_v4(), rt(1), ®istry) + .await + .unwrap(); + + flip.await.unwrap(); + assert_eq!(resolved.outcome, JoinOutcome::LocalOwner { generation: 6 }); + assert_eq!( + resolved.acquired.as_ref().map(HuddleLease::generation), + Some(6), + "re-acquire mints a fresh lease for the caller to install" + ); + } + + /// The window never resolves (winner wedged, entry never installed, room + /// never releases): the loop exhausts and fails closed rather than admit an + /// ownerless owner. The handler surfaces this to the client exactly like a + /// lost CAS. + #[tokio::test] + async fn owner_ready_fails_closed_when_window_never_resolves() { + // Perpetual LocalOwner/no-entry: owner_of always names us, registry + // stays empty, acquire is never scripted (and never reached). The + // bounded loop (~500ms real time) must terminate and fail closed. + let dir = FakeDir::owned_by(Ownership { + owner_runtime_id: rt(1), + generation: 5, + }); + let registry = HuddleOwnerRegistry::new(); + + let err = resolve_join_owner_ready(&dir, community(), Uuid::new_v4(), rt(1), ®istry) + .await + .unwrap_err(); + assert!( + matches!(err, MeshError::Transport(_)), + "exhaustion fails closed with a transient error, not an ownerless success" + ); + } + + /// The owner-side control loop, given the room's `lost` from the registry, + /// emits a proactive `Goodbye(StaleGeneration)` to the non-owner pod when + /// the lease is lost — the fan-out the live-attach seam drives. The + /// acceptor reads `lost` from the shared registry keyed by `session_id`, + /// not from a per-call argument. + #[tokio::test] + async fn serve_control_loop_emits_stale_generation_goodbye_on_loss() { + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + + // This pod owns the room: install its owner entry so the acceptor's + // `lost_for(session_id)` returns the room's loss signal. + let owners = Arc::new(HuddleOwnerRegistry::new()); + let lost = owners.install_for_test(session_id, fenced.generation); + + let acceptor = HuddleControlAcceptor::new( + Arc::new(AudioRoomManager::new()), + Arc::new(NullTransport) as Arc, + Arc::new(FakeDir::default()), + owner_rt, + Arc::clone(&owners), + ); + + let (owner_stream, mut client) = stream_pair(); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + // Owner observes lease loss → proactive Goodbye down the client stream. + lost.cancel(); + + let frame = tokio::time::timeout(Duration::from_secs(2), client.recv_frame()) + .await + .expect("goodbye arrives") + .unwrap() + .unwrap(); + assert!( + matches!( + frame, + MeshStreamFrame::Goodbye { + reason: GoodbyeReason::StaleGeneration, + .. + } + ), + "expected Goodbye(StaleGeneration), got {frame:?}" + ); + served.await.unwrap().unwrap(); + } + + /// Owner drain is distinct from fenced loss: control streams emit + /// `Goodbye(Draining)` so non-owner clients rejoin for rollout rather than + /// recording a stale-generation loss. + #[tokio::test] + async fn serve_control_loop_emits_draining_goodbye_on_drain() { + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + + let draining = CancellationToken::new(); + let acceptor = HuddleControlAcceptor::new( + Arc::new(AudioRoomManager::new()), + Arc::new(NullTransport) as Arc, + Arc::new(FakeDir::default()), + owner_rt, + Arc::new(HuddleOwnerRegistry::new()), + ); + + let (owner_stream, mut client) = stream_pair(); + let draining_for_loop = draining.clone(); + let served = tokio::spawn(async move { + acceptor + .serve_control_loop(from, fenced, owner_stream, None, Some(draining_for_loop)) + .await + }); + + draining.cancel(); + + let frame = tokio::time::timeout(Duration::from_secs(2), client.recv_frame()) + .await + .expect("goodbye arrives") + .unwrap() + .unwrap(); + assert!( + matches!( + frame, + MeshStreamFrame::Goodbye { + reason: GoodbyeReason::Draining, + .. + } + ), + "expected Goodbye(Draining), got {frame:?}" + ); + served.await.unwrap().unwrap(); + } + + #[test] + fn fence_rejection_classifies_only_fence_errors() { + assert_eq!( + FenceRejection::from_mesh_error(&MeshError::StaleGeneration { + session_id: Uuid::nil(), + frame_generation: 1, + known_generation: 2, + }), + Some(FenceRejection::StaleGeneration) + ); + assert_eq!( + FenceRejection::from_mesh_error(&MeshError::Transport("x".into())), + None + ); + } + + #[test] + fn fenced_header_uses_local_id_for_local_owner_and_owner_id_for_remote() { + let s = Uuid::new_v4(); + let local = JoinOutcome::LocalOwner { generation: 5 }; + assert_eq!( + local.fenced_header(s, rt(1)), + FencedHeader { + session_id: s, + generation: 5, + owner_runtime_id: rt(1), + } + ); + let remote = JoinOutcome::RemoteOwner { + owner_runtime_id: rt(2), + generation: 8, + }; + assert_eq!( + remote.fenced_header(s, rt(1)), + FencedHeader { + session_id: s, + generation: 8, + owner_runtime_id: rt(2), + } + ); + } + + #[test] + fn admission_errors_map_to_wire_rejections() { + assert_eq!( + admission_to_rejection(AdmissionError::Full), + RegisterRejection::RoomFull + ); + assert_eq!( + admission_to_rejection(AdmissionError::Ended), + RegisterRejection::RoomEnded + ); + assert_eq!( + admission_to_rejection(AdmissionError::VersionMismatch { + pinned: 2, + requested: 1 + }), + RegisterRejection::VersionMismatch { + pinned: 2, + requested: 1 + } + ); + } + + #[test] + fn media_datagram_tags_owner_index_and_stamps_fence() { + let fenced = FencedHeader { + session_id: Uuid::new_v4(), + generation: 9, + owner_runtime_id: rt(2), + }; + // Owner-assigned index is the first payload byte; client bytes follow. + let d0 = media_datagram(42, fenced, 0, &[0xDE, 0xAD]); + assert_eq!(d0.payload, vec![42, 0xDE, 0xAD]); + assert_eq!(d0.fenced, fenced); + assert_eq!(d0.seq, 0); + // Empty client frame still carries the index byte (owner tolerates it). + let d1 = media_datagram(7, fenced, 3, &[]); + assert_eq!(d1.payload, vec![7]); + assert_eq!(d1.seq, 3); + } + + // ── Non-owner teardown reader: wire signal → HuddleTeardownCause ────────── + // + // Drive `read_teardown_cause` over the in-memory stream pair: the "owner" + // half writes a terminal signal, and the "client" half's reader must + // classify it. Every arm of `GoodbyeReason` plus a bare stream close. + + /// Send a `Goodbye(reason)` from the owner half and assert the reader maps + /// it to `expected`. + async fn assert_goodbye_maps(reason: GoodbyeReason, expected: HuddleTeardownCause) { + let fenced = fenced_owned_by(rt(2), Uuid::new_v4()); + let (mut owner, mut client) = stream_pair(); + owner + .send_frame(MeshStreamFrame::Goodbye { fenced, reason }) + .await + .unwrap(); + assert_eq!(read_teardown_cause(&mut client).await, expected); + } + + #[tokio::test] + async fn reader_maps_stale_generation_to_owner_lost() { + assert_goodbye_maps( + GoodbyeReason::StaleGeneration, + HuddleTeardownCause::OwnerLost, + ) + .await; + } + + #[tokio::test] + async fn reader_maps_draining_to_owner_draining() { + assert_goodbye_maps(GoodbyeReason::Draining, HuddleTeardownCause::OwnerDraining).await; + } + + #[tokio::test] + async fn reader_maps_session_ended_to_ordinary_disconnect() { + assert_goodbye_maps( + GoodbyeReason::SessionEnded, + HuddleTeardownCause::SessionEnded, + ) + .await; + } + + #[tokio::test] + async fn reader_maps_bare_stream_close_to_stream_closed() { + let (owner, mut client) = stream_pair(); + // Owner pod dies mid-flight: the send half drops with no Goodbye, so the + // client's recv sees a clean close. + drop(owner); + assert_eq!( + read_teardown_cause(&mut client).await, + HuddleTeardownCause::StreamClosed + ); + } + + #[tokio::test] + async fn reader_skips_non_terminal_frames_then_tears_down() { + // A future owner→client roster delta (a `Data` frame) must NOT be read + // as teardown; the reader keeps going and returns on the later Goodbye. + let fenced = fenced_owned_by(rt(2), Uuid::new_v4()); + let (mut owner, mut client) = stream_pair(); + owner + .send_frame(MeshStreamFrame::Data { + fenced, + payload: vec![0xAB, 0xCD], + }) + .await + .unwrap(); + owner + .send_frame(MeshStreamFrame::Goodbye { + fenced, + reason: GoodbyeReason::StaleGeneration, + }) + .await + .unwrap(); + assert_eq!( + read_teardown_cause(&mut client).await, + HuddleTeardownCause::OwnerLost + ); + } + + /// The client-initiated clean close emits `UnregisterPeer` then + /// `Goodbye(SessionEnded)` on the owner's control stream, in that order. + #[tokio::test] + async fn clean_close_sends_unregister_then_goodbye() { + let fenced = fenced_owned_by(rt(2), Uuid::new_v4()); + let (mut owner, mut client) = stream_pair(); + send_clean_close(&mut client, fenced, "client-a").await; + + match owner.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => assert_eq!( + decode_control(&payload).unwrap(), + HuddleControlMsg::UnregisterPeer { + pubkey: "client-a".into() + } + ), + other => panic!("expected UnregisterPeer Data, got {other:?}"), + } + match owner.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Goodbye { reason, .. } => { + assert_eq!(reason, GoodbyeReason::SessionEnded) + } + other => panic!("expected Goodbye(SessionEnded), got {other:?}"), + } + } +} diff --git a/crates/buzz-relay/src/audio/mesh.rs b/crates/buzz-relay/src/audio/mesh.rs new file mode 100644 index 0000000000..0b26a164f5 --- /dev/null +++ b/crates/buzz-relay/src/audio/mesh.rs @@ -0,0 +1,391 @@ +//! Cross-pod huddle audio: owner fan-out over the relay mesh. +//! +//! Today a huddle's audio only fans out within a single pod +//! ([`super::room::Room::broadcast_frame`]). Under horizontal scaling, two +//! participants can land on different pods and never hear each other — which is +//! why [`super::handler`] rejects joins with `huddle_audio_unavailable` when the +//! deployment sets `huddle_audio_available = false`. This module removes that +//! wall by routing audio across the mesh to the pod that *owns* the huddle. +//! +//! ## Owner-authoritative model +//! +//! One pod owns a huddle: the holder of the Redis fenced CAS lease for +//! `session_id == channel_id` (the session directory, Perci's lane, exposed to +//! us through [`HuddleOwnerDirectory`]). That pod hosts the single +//! [`Room`](super::room::Room) — +//! the sole allocator of the 0..=254 `peer_index` space, so indices can never +//! collide across pods. Non-owner pods are thin: they register their local +//! clients as *remote peers* in the owner's room over a reliable +//! [`Profile::HuddleControl`] stream, forward those clients' Opus frames to the +//! owner as datagrams, and deliver the owner's fan-out back verbatim. +//! +//! ## The payload invariant (why this needs no wire change) +//! +//! The client sends `[8B v2 header][opaque Opus]`; the relay parses the header +//! for telemetry only and forwards the frame opaquely, and `broadcast_frame` +//! prepends a 1-byte `peer_index`. That `peer_index` is relay-added *routing* +//! metadata — it never touches ciphertext — so the whole byte string +//! `[peer_index][v2 header][Opus]` is exactly what [`MeshDatagram::payload`] is +//! for: opaque to encryption, owned by the routing plane. **peer_index is +//! always the first byte of a media datagram payload, both directions.** The +//! client's WebSocket wire format is byte-identical to a single-pod huddle. +//! +//! ## Room stays pure +//! +//! `Room` never learns about the mesh. A remote participant is an ordinary +//! [`AudioPeer`] whose `audio_tx` receiver is drained by a task that wraps each +//! frame in a [`MeshDatagram`] and calls [`RelayPeerTransport::send_datagram`]. +//! The in-pod fan-out is reused unchanged; only a peer's *sink* differs. +//! +//! ## Fencing (law, not exempt for media) +//! +//! Every datagram carries a [`FencedHeader`]. Both ends reject frames whose +//! generation is stale for the session — a late datagram from a dead generation +//! is dropped, which for lossy audio is indistinguishable from packet loss and +//! is therefore exactly correct. Monotonicity of `generation` across owner death +//! is guaranteed by the directory's companion INCR counter (session-directory +//! lane); this module trusts that and only enforces "reject < known". + +use std::sync::Arc; + +use bytes::Bytes; +use tokio::sync::mpsc; +use tracing::{debug, warn}; +use uuid::Uuid; + +use buzz_relay_mesh::{FencedHeader, MeshDatagram, RelayPeerTransport, RuntimeId}; + +use super::room::AudioRoomManager; + +/// The slice of the session directory that huddle audio needs. +/// +/// Implemented by the session-directory lane (Perci) over the Redis fenced CAS +/// lease. Kept narrow on purpose: audio only asks "who owns this huddle, and at +/// what generation?" — it never acquires, renews, or releases leases (that is +/// the owning pod's session layer). Returning [`None`] means "no live owner" +/// (the caller may then acquire, on the owner path). +pub trait HuddleOwnerDirectory: Send + Sync + 'static { + /// Current `{owner_runtime_id, generation}` for a huddle session, or `None` + /// if no live lease exists. Cheap/cached; called on the join path, not per + /// frame. + fn owner_of(&self, session_id: Uuid) -> Option; +} + +/// A resolved huddle ownership snapshot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Ownership { + /// Boot-unique mesh endpoint key of the pod currently holding the lease. + pub owner_runtime_id: RuntimeId, + /// Fenced generation of this ownership epoch; monotonic per session. + pub generation: u64, +} + +/// Tracks the highest generation this pod has observed per session, so stale +/// frames are rejected at every hop (fencing law). Monotonic-only: a frame is +/// accepted iff its generation is `>=` the highest seen; observing a higher +/// generation advances the floor (and signals a takeover the caller may act on). +#[derive(Default)] +pub struct GenerationFloor { + seen: dashmap::DashMap, +} + +impl GenerationFloor { + /// Create an empty floor (no sessions observed yet). + pub fn new() -> Self { + Self { + seen: dashmap::DashMap::new(), + } + } + + /// Check a frame's generation against the floor for its session. + /// + /// - `Accept` — generation is current (`== floor`) or advances it (`> + /// floor`, a takeover we now pin). + /// - `RejectStale { known }` — generation is below the floor; drop the + /// frame. This is the fence. + pub fn check(&self, session_id: Uuid, generation: u64) -> FenceVerdict { + use dashmap::mapref::entry::Entry; + match self.seen.entry(session_id) { + Entry::Occupied(mut e) => { + let known = *e.get(); + if generation < known { + FenceVerdict::RejectStale { known } + } else { + if generation > known { + *e.get_mut() = generation; + } + FenceVerdict::Accept { + advanced: generation > known, + } + } + } + Entry::Vacant(e) => { + e.insert(generation); + FenceVerdict::Accept { advanced: false } + } + } + } + + /// Drop all state for a session (room ended / owner teardown). + pub fn forget(&self, session_id: Uuid) { + self.seen.remove(&session_id); + } +} + +/// Outcome of a fence check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FenceVerdict { + /// Frame is live. `advanced` is true when it bumped the floor (takeover). + Accept { + /// True when this frame's generation exceeded the prior floor, + /// signalling an ownership takeover the caller may act on. + advanced: bool, + }, + /// Frame is from a superseded generation; drop it. + RejectStale { + /// The highest generation observed for this session — the fence floor. + known: u64, + }, +} + +/// Handles inbound mesh media for huddles on this pod. +/// +/// Registered as (part of) the relay's [`buzz_relay_mesh::InboundHandler`]. +/// Datagrams are delivered to local room peers; the fence is enforced here so +/// no stale media reaches a client. The `HuddleControl` stream path (remote +/// peer registration) is driven from [`super::handler`] on join/leave and is +/// wired in a following change — this type owns the datagram half and the +/// shared fence state both halves consult. +pub struct MeshAudioRouter { + rooms: Arc, + fence: Arc, + local_runtime_id: RuntimeId, +} + +impl MeshAudioRouter { + /// Construct a router over this pod's rooms, tagged with the local runtime + /// identity (used to distinguish owner vs non-owner delivery paths). + pub fn new(rooms: Arc, local_runtime_id: RuntimeId) -> Self { + Self::with_fence(rooms, local_runtime_id, Arc::new(GenerationFloor::new())) + } + + /// Construct a router that enforces an externally owned generation floor. + /// + /// Used by the boot wiring (`mesh_boot::wire_mesh_consumers`) so the + /// datagram hot path and session teardown (`GenerationFloor::forget`, + /// reached via `MeshHandle::audio_fence`) consult exactly one floor. + pub fn with_fence( + rooms: Arc, + local_runtime_id: RuntimeId, + fence: Arc, + ) -> Self { + Self { + rooms, + fence, + local_runtime_id, + } + } + + /// Shared fence state, so the `HuddleControl` stream path (join/leave) and + /// the datagram path enforce one generation floor per session. + pub fn fence(&self) -> Arc { + Arc::clone(&self.fence) + } + + /// This pod's mesh runtime identity. + pub fn local_runtime_id(&self) -> RuntimeId { + self.local_runtime_id + } + + /// Deliver an inbound media datagram to the addressed local huddle. + /// + /// The payload is `[peer_index][v2 header][Opus]` — already prefixed by the + /// sender (the owner, when fanning out to us; or a non-owner client's pod, + /// when we are the owner). We fence, then push the payload into every + /// *local* peer's audio sink **except** the peer whose index authored it, + /// mirroring `broadcast_frame`'s "everyone but the sender" rule so a speaker + /// never hears themselves. + /// + /// Returns the fence verdict for observability/tests. Does not itself + /// re-fan across the mesh: if we are the owner, cross-pod fan-out happens + /// through the remote peers' mesh sinks during `broadcast_frame`, so an + /// owner-side inbound datagram only needs local delivery here. + pub fn on_media_datagram(&self, dgram: &MeshDatagram) -> FenceVerdict { + let session_id = dgram.fenced.session_id; + let verdict = self.fence.check(session_id, dgram.fenced.generation); + if let FenceVerdict::RejectStale { known } = verdict { + debug!( + %session_id, + frame_generation = dgram.fenced.generation, + known_generation = known, + "dropping stale-generation media datagram (fence)" + ); + return verdict; + } + + let Some(room) = self.rooms.get(session_id) else { + // No local room for this session: nothing to deliver to. Not an + // error — membership can race a datagram in flight. + return verdict; + }; + + let Some((&author_index, rest)) = dgram.payload.split_first() else { + warn!(%session_id, "empty media datagram payload — dropping"); + return verdict; + }; + // Reconstruct the exact on-wire frame the local fan-out uses: + // [peer_index][v2 header][Opus]. `rest` is [v2 header][Opus]; the + // prefix is the author's index. We hand peers the already-prefixed + // bytes and skip re-broadcasting to the author's own index. + let mut prefixed = bytes::BytesMut::with_capacity(dgram.payload.len()); + prefixed.extend_from_slice(&[author_index]); + prefixed.extend_from_slice(rest); + let prefixed = prefixed.freeze(); + + room.deliver_prefixed(author_index, prefixed); + verdict + } +} + +/// A sink that forwards a remote peer's fanned-out frames onto the mesh. +/// +/// Constructed on the owner pod for each *remote* participant: the owner's +/// `Room` sees the remote peer as an ordinary [`AudioPeer`] whose `audio_tx` +/// feeds this task, which wraps each frame as a [`MeshDatagram`] and sends it to +/// the pod that hosts that participant. Drops on a disconnected/oversize peer — +/// realtime audio never blocks fan-out on one slow remote link. +pub fn spawn_remote_peer_sink( + transport: Arc, + to: RuntimeId, + fenced: FencedHeader, + mut frames: mpsc::Receiver, +) { + tokio::spawn(async move { + let mut seq: u64 = 0; + while let Some(frame) = frames.recv().await { + let dgram = MeshDatagram { + fenced, + seq, + payload: frame.to_vec(), + }; + seq = seq.wrapping_add(1); + if let Err(e) = transport.send_datagram(to, dgram) { + // Disconnected peer or oversize frame: drop and keep going. + // The MTU case is the ship-gate's job to prevent; here we just + // never let one bad link stall the room. + debug!(%to, "remote peer datagram send failed: {e}"); + } + } + debug!(%to, "remote peer sink closed"); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rt(b: u8) -> RuntimeId { + RuntimeId([b; 32]) + } + + fn fenced(session: Uuid, generation: u64) -> FencedHeader { + FencedHeader { + session_id: session, + generation, + owner_runtime_id: rt(0xAA), + } + } + + #[test] + fn fence_accepts_first_and_equal_and_higher() { + let f = GenerationFloor::new(); + let s = Uuid::new_v4(); + assert_eq!(f.check(s, 5), FenceVerdict::Accept { advanced: false }); + assert_eq!(f.check(s, 5), FenceVerdict::Accept { advanced: false }); + assert_eq!(f.check(s, 6), FenceVerdict::Accept { advanced: true }); + } + + #[test] + fn fence_rejects_stale_after_advance() { + let f = GenerationFloor::new(); + let s = Uuid::new_v4(); + assert_eq!(f.check(s, 10), FenceVerdict::Accept { advanced: false }); + // A late frame from the superseded generation is rejected. + assert_eq!(f.check(s, 9), FenceVerdict::RejectStale { known: 10 }); + // The floor is unchanged by a rejected frame. + assert_eq!(f.check(s, 10), FenceVerdict::Accept { advanced: false }); + } + + #[test] + fn fence_is_per_session() { + let f = GenerationFloor::new(); + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + assert_eq!(f.check(a, 7), FenceVerdict::Accept { advanced: false }); + // A different session starts its own floor. + assert_eq!(f.check(b, 1), FenceVerdict::Accept { advanced: false }); + assert_eq!(f.check(a, 6), FenceVerdict::RejectStale { known: 7 }); + } + + #[test] + fn fence_forget_resets_floor() { + let f = GenerationFloor::new(); + let s = Uuid::new_v4(); + f.check(s, 20); + f.forget(s); + // After forget, a lower generation is accepted as a fresh floor — + // used on room-end/teardown so a rejoin isn't fenced by a dead session. + assert_eq!(f.check(s, 3), FenceVerdict::Accept { advanced: false }); + } + + #[test] + fn router_drops_stale_datagram_without_delivering() { + let rooms = Arc::new(AudioRoomManager::new()); + let router = MeshAudioRouter::new(Arc::clone(&rooms), rt(1)); + let s = Uuid::new_v4(); + // Establish a floor at generation 5. + assert!(matches!( + router.on_media_datagram(&MeshDatagram { + fenced: fenced(s, 5), + seq: 0, + payload: vec![0, 1, 2], + }), + FenceVerdict::Accept { .. } + )); + // A stale frame is rejected. + assert_eq!( + router.on_media_datagram(&MeshDatagram { + fenced: fenced(s, 4), + seq: 1, + payload: vec![0, 1, 2], + }), + FenceVerdict::RejectStale { known: 5 } + ); + } + + #[test] + fn router_tolerates_missing_room_and_empty_payload() { + let rooms = Arc::new(AudioRoomManager::new()); + let router = MeshAudioRouter::new(Arc::clone(&rooms), rt(1)); + let s = Uuid::new_v4(); + // No local room for this session: accepted by fence, no panic. + assert!(matches!( + router.on_media_datagram(&MeshDatagram { + fenced: fenced(s, 1), + seq: 0, + payload: vec![7, 8], + }), + FenceVerdict::Accept { .. } + )); + // Empty payload after a valid fence: dropped, no panic. + let s2 = Uuid::new_v4(); + assert!(matches!( + router.on_media_datagram(&MeshDatagram { + fenced: fenced(s2, 1), + seq: 0, + payload: vec![], + }), + FenceVerdict::Accept { .. } + )); + } +} diff --git a/crates/buzz-relay/src/audio/mod.rs b/crates/buzz-relay/src/audio/mod.rs index 4725e1693d..c6c54d7f2e 100644 --- a/crates/buzz-relay/src/audio/mod.rs +++ b/crates/buzz-relay/src/audio/mod.rs @@ -10,6 +10,8 @@ //! ``` pub mod handler; +pub mod join; +pub mod mesh; pub mod room; pub mod wire; diff --git a/crates/buzz-relay/src/audio/room.rs b/crates/buzz-relay/src/audio/room.rs index d0a70fbe7c..55779ccb81 100644 --- a/crates/buzz-relay/src/audio/room.rs +++ b/crates/buzz-relay/src/audio/room.rs @@ -11,7 +11,7 @@ use bytes::Bytes; use dashmap::DashMap; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::{broadcast, mpsc}; use uuid::Uuid; /// A connected audio peer. @@ -47,6 +47,36 @@ const CTRL_CHANNEL_CAPACITY: usize = 32; /// is reasonable. The 255 index space is the hard limit; this is the soft one. const MAX_PEERS_PER_ROOM: usize = 25; +/// One authoritative owner-roster entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RosterPeer { + /// Nostr pubkey hex. + pub pubkey: String, + /// Owner-assigned media routing index. + pub peer_index: u8, +} + +/// A complete owner-roster snapshot at one monotonic revision. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RosterSnapshot { + /// Owner-monotonic roster revision. + pub revision: u64, + /// Complete participants at this revision. + pub peers: Vec, +} + +/// One ordered owner-roster mutation. Receivers that miss a revision must +/// replace local state from a fresh [`RosterSnapshot`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RosterDelta { + /// Owner-monotonic roster revision. + pub revision: u64, + /// Newly admitted peer, when this is a join. + pub joined: Option, + /// Removed peer, when this is a leave. + pub left: Option, +} + /// Reason a peer was refused entry to a room. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AdmissionError { @@ -95,6 +125,7 @@ struct AdmissionGuard { /// `version_pin_persists_across_peer_churn` for the test that pins /// this behavior. pinned_version: Option, + roster_revision: u64, } impl AdmissionGuard { @@ -104,6 +135,7 @@ impl AdmissionGuard { free: Vec::new(), ended: false, pinned_version: None, + roster_revision: 0, } } @@ -132,15 +164,20 @@ pub struct Room { pub peers: DashMap, /// Admission gate: index allocator + ended flag under one lock. guard: std::sync::Mutex, + /// Ordered authoritative roster mutations. Lag is recoverable from + /// [`Self::roster_snapshot`], so the owner never blocks admission. + roster_tx: broadcast::Sender, } impl Room { /// Create an empty room for the given channel. pub fn new(channel_id: Uuid) -> Self { + let (roster_tx, _) = broadcast::channel(64); Self { channel_id, peers: DashMap::new(), guard: std::sync::Mutex::new(AdmissionGuard::new()), + roster_tx, } } @@ -217,22 +254,99 @@ impl Room { self.peers.insert( peer_id, AudioPeer { - pubkey, + pubkey: pubkey.clone(), audio_tx, ctrl_tx, peer_index, }, ); - drop(g); // Release lock after insert. + g.roster_revision = g.roster_revision.wrapping_add(1); + let delta = RosterDelta { + revision: g.roster_revision, + joined: Some(RosterPeer { pubkey, peer_index }), + left: None, + }; + let _ = self.roster_tx.send(delta); + drop(g); // Release lock after ordered roster publication. Ok((peer_id, peer_index, audio_rx, ctrl_rx)) } + /// Add a non-owner ingress peer at the index already allocated by the + /// authoritative owner. No client-visible state is emitted before this + /// succeeds, so a remote client has exactly one identity end-to-end. + pub fn add_peer_at_index( + &self, + pubkey: String, + requested_version: u8, + peer_index: u8, + ) -> Result<(Uuid, mpsc::Receiver, mpsc::Receiver), AdmissionError> { + let mut g = self.guard.lock().map_err(|_| AdmissionError::Ended)?; + if g.ended { + return Err(AdmissionError::Ended); + } + if self.peers.len() >= MAX_PEERS_PER_ROOM + || self.peers.iter().any(|peer| peer.peer_index == peer_index) + { + return Err(AdmissionError::Full); + } + if let Some(pinned) = g.pinned_version { + if pinned != requested_version { + return Err(AdmissionError::VersionMismatch { + pinned, + requested: requested_version, + }); + } + } + g.pinned_version.get_or_insert(requested_version); + // Keep a later local allocation from colliding if ownership changes + // while this room is still winding down. Skipped lower indices are a + // bounded handoff cost; a fresh room resets the allocator. + g.free.retain(|idx| *idx != peer_index); + if peer_index >= g.next_fresh { + g.next_fresh = peer_index.saturating_add(1); + } + + let peer_id = Uuid::new_v4(); + let (audio_tx, audio_rx) = mpsc::channel(AUDIO_CHANNEL_CAPACITY); + let (ctrl_tx, ctrl_rx) = mpsc::channel(CTRL_CHANNEL_CAPACITY); + self.peers.insert( + peer_id, + AudioPeer { + pubkey: pubkey.clone(), + audio_tx, + ctrl_tx, + peer_index, + }, + ); + g.roster_revision = g.roster_revision.wrapping_add(1); + let delta = RosterDelta { + revision: g.roster_revision, + joined: Some(RosterPeer { pubkey, peer_index }), + left: None, + }; + let _ = self.roster_tx.send(delta); + drop(g); + Ok((peer_id, audio_rx, ctrl_rx)) + } + /// Remove a peer and recycle its index. pub fn remove_peer(&self, peer_id: Uuid) { + let Ok(mut g) = self.guard.lock() else { + return; + }; if let Some((_, peer)) = self.peers.remove(&peer_id) { - if let Ok(mut g) = self.guard.lock() { - g.release(peer.peer_index); - } + g.release(peer.peer_index); + g.roster_revision = g.roster_revision.wrapping_add(1); + let delta = RosterDelta { + revision: g.roster_revision, + joined: None, + left: Some(RosterPeer { + pubkey: peer.pubkey, + peer_index: peer.peer_index, + }), + }; + let _ = self.roster_tx.send(delta); + drop(g); } } @@ -242,22 +356,30 @@ impl Room { /// `add_peer` to sneak in between removal and the ended flag. /// Returns `(peer_index, should_auto_end)`. pub fn remove_peer_and_check_ended(&self, peer_id: Uuid) -> Option<(u8, bool)> { + let mut g = self.guard.lock().ok()?; let (_, peer) = self.peers.remove(&peer_id)?; let peer_index = peer.peer_index; - let should_end = if let Ok(mut g) = self.guard.lock() { - g.release(peer_index); - // Only the first task to see empty + !ended wins the auto-end. - // This prevents duplicate archive/48103 when two peers disconnect - // simultaneously and both see is_empty() == true. - if !g.ended && self.peers.is_empty() { - g.ended = true; - true - } else { - false - } + g.release(peer_index); + g.roster_revision = g.roster_revision.wrapping_add(1); + let delta = RosterDelta { + revision: g.roster_revision, + joined: None, + left: Some(RosterPeer { + pubkey: peer.pubkey, + peer_index, + }), + }; + // Only the first task to see empty + !ended wins the auto-end. + // This prevents duplicate archive/48103 when two peers disconnect + // simultaneously and both see is_empty() == true. + let should_end = if !g.ended && self.peers.is_empty() { + g.ended = true; + true } else { false }; + let _ = self.roster_tx.send(delta); + drop(g); Some((peer_index, should_end)) } @@ -284,6 +406,24 @@ impl Room { } } + /// Deliver an already-`[peer_index]`-prefixed frame that arrived over the + /// mesh to every local peer except the one whose `peer_index` authored it. + /// + /// Used by the cross-pod media path ([`super::mesh`]): the frame is + /// byte-identical to what `broadcast_frame` produces, but the author is a + /// *remote* participant identified only by its (owner-assigned) index, not + /// a local peer UUID. Skipping by index keeps a participant whose own frame + /// round-tripped owner→back-to-their-pod from hearing themselves. Drops on + /// full — real-time audio never queues. + pub fn deliver_prefixed(&self, author_index: u8, prefixed: Bytes) { + for entry in self.peers.iter() { + if entry.peer_index == author_index { + continue; + } + let _ = entry.audio_tx.try_send(prefixed.clone()); + } + } + /// Send a JSON control message to all peers via the control channel. /// Separate from audio so control is never starved by audio backpressure. /// Control messages (joined/left) are state-bearing — the client's @@ -305,6 +445,32 @@ impl Room { } } + /// Subscribe to ordered roster mutations. A lagged receiver must call + /// [`Self::roster_snapshot`] and continue from that snapshot's revision. + pub fn subscribe_roster(&self) -> broadcast::Receiver { + self.roster_tx.subscribe() + } + + /// Capture a complete roster and its revision atomically with respect to + /// admission/removal. Subscribe before calling this to close the + /// snapshot-to-delta race; stale deltas at or below `revision` are ignored. + pub fn roster_snapshot(&self) -> RosterSnapshot { + let g = self.guard.lock().unwrap_or_else(|e| e.into_inner()); + let mut peers = self + .peers + .iter() + .map(|e| RosterPeer { + pubkey: e.pubkey.clone(), + peer_index: e.peer_index, + }) + .collect::>(); + peers.sort_by_key(|peer| peer.peer_index); + RosterSnapshot { + revision: g.roster_revision, + peers, + } + } + /// All `(pubkey, peer_index)` pairs in the room. pub fn peer_pubkeys(&self) -> Vec<(String, u8)> { self.peers @@ -340,6 +506,14 @@ impl AudioRoomManager { .clone() } + /// Look up an existing room without creating one. Returns `None` when this + /// pod hosts no room for the channel — the cross-pod media path uses this + /// so an inbound datagram for a session we don't participate in is dropped, + /// never materializing an empty room. + pub fn get(&self, channel_id: Uuid) -> Option> { + self.rooms.get(&channel_id).map(|r| r.clone()) + } + /// Remove the room if it has no peers. Returns `true` if the room was removed. pub fn cleanup_if_empty(&self, channel_id: Uuid) -> bool { self.rooms @@ -362,6 +536,52 @@ mod tests { Room::new(Uuid::new_v4()) } + #[test] + fn owner_assigned_index_is_preserved_and_reserved() { + let room = fresh_room(); + let (_local_id, local_index, ..) = room.add_peer("owner-local".into(), 2).unwrap(); + assert_eq!(local_index, 0); + + let (remote_id, _audio, _ctrl) = room + .add_peer_at_index("remote".into(), 2, 7) + .expect("owner-assigned index admits"); + assert_eq!(room.peers.get(&remote_id).unwrap().peer_index, 7); + + let (_next_id, next_index, ..) = room.add_peer("next-local".into(), 2).unwrap(); + assert_eq!( + next_index, 8, + "local allocation cannot collide with owner index" + ); + } + + #[test] + fn roster_revisions_are_ordered_and_snapshot_is_authoritative() { + let room = fresh_room(); + let mut deltas = room.subscribe_roster(); + let (alice, alice_index, ..) = room.add_peer("alice".into(), 2).unwrap(); + let (_bob, bob_index, ..) = room.add_peer("bob".into(), 2).unwrap(); + room.remove_peer(alice); + + assert_eq!(deltas.try_recv().unwrap().revision, 1); + assert_eq!(deltas.try_recv().unwrap().revision, 2); + let leave = deltas.try_recv().unwrap(); + assert_eq!(leave.revision, 3); + assert_eq!( + leave.left.as_ref().map(|peer| peer.peer_index), + Some(alice_index) + ); + + let snapshot = room.roster_snapshot(); + assert_eq!(snapshot.revision, 3); + assert_eq!( + snapshot.peers, + vec![RosterPeer { + pubkey: "bob".into(), + peer_index: bob_index, + }] + ); + } + /// First peer's `requested_version` becomes the room's pin; later peers /// requesting the same version are admitted normally. #[test] diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index eaa67a052d..c11dca9cc0 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -1,6 +1,7 @@ //! Relay configuration from environment variables. use std::net::SocketAddr; +use std::time::Duration; use thiserror::Error; use tracing::warn; @@ -33,6 +34,8 @@ pub struct Config { pub redis_url: String, /// Public WebSocket URL of this relay, advertised in NIP-11. pub relay_url: String, + /// Public WebSocket URL of the dedicated device-pairing relay, when configured. + pub pairing_relay_url: Option, /// Maximum number of concurrent WebSocket connections. pub max_connections: usize, /// Maximum number of concurrently executing message handlers. @@ -93,11 +96,47 @@ pub struct Config { /// service lands. pub huddle_audio_available: bool, + /// Inter-relay mesh configuration (`BUZZ_MESH`, `BUZZ_MESH_BIND_ADDR`). + /// Opt-in: mesh forms only when `BUZZ_MESH=on` is explicit. The default + /// (absent/off) is exact single-instance behavior — no bind, no Redis + /// registry write — so an image upgrade with untouched env is a strict + /// no-regression rollout. + pub mesh: buzz_relay_mesh::MeshConfig, + + /// Testbed-only reliable-stream echo consumer (`BUZZ_MESH_DEMO_ECHO`). + /// When `on`, the owner side of an inbound reliable mesh stream echoes + /// every validated `Data` frame back to the sender — a transport/ + /// session-routing smoke for cross-pod evidence runs, NOT a product flow. + /// Same strict opt-in as `BUZZ_MESH`; default off means inbound reliable + /// streams are accepted, logged, and closed (no session consumer yet). + pub mesh_demo_echo: bool, + /// Optional hex-encoded pubkey of the relay owner. /// When set, this pubkey is automatically bootstrapped into `relay_members` /// with the `owner` role on first startup. pub relay_owner_pubkey: Option, + /// Canonical HTTP origin of the deployment-global operator API. + /// + /// Every operator NIP-98 `u` tag is verified against this origin, independent + /// of the inbound HTTP `Host` header and tenant registry. Required when + /// `RELAY_OPERATOR_PUBKEYS` is non-empty. Set via `RELAY_OPERATOR_API_ORIGIN` + /// as an `http://` or `https://` origin with no path, query, or fragment. + pub relay_operator_api_origin: Option, + + /// Deployment-level relay operator pubkeys allowed to use the + /// `/operator/communities` management endpoints. + /// + /// Unlike `relay_owner_pubkey` (a role *within* the deployment community), + /// operators span tenants: they may create new communities and bootstrap + /// initial owners, but hold no implicit tenant membership row. + /// Empty (the default) disables community provisioning entirely — fail closed. + /// + /// Set via `RELAY_OPERATOR_PUBKEYS` as a comma-separated list of 64-char + /// hex pubkeys. Invalid entries are rejected at startup (config error), not + /// skipped — a typo must not silently disable an operator. + pub relay_operator_pubkeys: Vec, + /// Allow NIP-OA owner attestation for relay membership. /// /// When `true` and `require_relay_membership` is also `true`, agents @@ -121,6 +160,10 @@ pub struct Config { /// Maximum media upload starts accepted from one pubkey per minute. pub media_uploads_per_minute: u32, + /// Require Blossom kind:24242 `t=get` auth plus relay membership before + /// serving media GET/HEAD. Default off for staged client rollout. + pub require_media_get_auth: bool, + /// Optional override for ephemeral channel TTL (in seconds). /// When set, any channel created with a TTL tag will use this value instead /// of the client-provided one. Useful for testing ephemeral expiry quickly. @@ -130,12 +173,10 @@ pub struct Config { /// Root directory for the relay's local git scratch. No per-repo bare repos /// or persistent git state live here — runtime reads/writes hydrate - /// ephemeral repos from object storage per request, and repo-name - /// uniqueness now lives in Postgres (`git_repo_names`), not on disk. Retained - /// for ephemeral working space and env compatibility; the relay no longer - /// depends on this path being persistent or shared across replicas, so it - /// needs no ReadWriteMany volume. (Removing the field entirely is a - /// follow-up cleanup once the deploy chart drops the git PVC mount.) + /// ephemeral repos from object storage per request, and all temporary Git + /// workspaces and buffered subprocess output are created beneath this path. + /// Repo-name uniqueness lives in Postgres (`git_repo_names`), not on disk, + /// so this directory need not be persistent or shared across replicas. pub git_repo_path: std::path::PathBuf, /// Maximum pack file size for git push (bytes). Default: 500 MB. pub git_max_pack_bytes: u64, @@ -152,10 +193,21 @@ pub struct Config { /// Used to authenticate internal policy endpoint requests. pub git_hook_hmac_secret: String, + /// Descriptor key identifier accepted in kind:30350 `exec` tags. + pub push_executor_key_id: String, + /// Exact HTTPS gateway endpoint used to submit client-authorized APNs delivery capabilities. + /// Push lease support is disabled when unset. + pub push_gateway_delivery_url: Option, + /// Hard timeout for one gateway delivery request. + pub push_gateway_timeout: Duration, + /// Optional path to the web UI `dist/` directory. - /// When set, the relay serves the SPA from this directory for browser requests. + /// When set, the relay serves the invite landing page and its static assets. /// When unset, no static file serving happens (relay behaves as before). pub web_dir: Option, + /// Whether the configured web bundle serves Git browser routes in addition + /// to the public invite landing page. Defaults to false. + pub serve_git_web_gui: bool, } fn parse_bind_addr(raw: &str) -> Result { @@ -163,6 +215,51 @@ fn parse_bind_addr(raw: &str) -> Result { .map_err(|e| ConfigError::InvalidBindAddr(e.to_string())) } +fn parse_operator_api_origin(raw: &str) -> Result { + let raw = raw.trim(); + let url = url::Url::parse(raw).map_err(|e| { + ConfigError::InvalidValue(format!("RELAY_OPERATOR_API_ORIGIN is not a valid URL: {e}")) + })?; + if !matches!(url.scheme(), "http" | "https") + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(ConfigError::InvalidValue( + "RELAY_OPERATOR_API_ORIGIN must be an http(s) origin with no credentials, path, query, or fragment" + .to_string(), + )); + } + Ok(raw.trim_end_matches('/').to_string()) +} + +const DEFAULT_PUSH_GATEWAY_DELIVERY_URL: &str = "https://push.buzz.xyz/v1/deliveries/apns"; + +fn parse_push_gateway_delivery_url(raw: &str) -> Result { + let url = url::Url::parse(raw.trim()).map_err(|e| { + ConfigError::InvalidValue(format!( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL is not a valid URL: {e}" + )) + })?; + if url.scheme() != "https" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/v1/deliveries/apns" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(ConfigError::InvalidValue( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTPS /v1/deliveries/apns URL without credentials, query, or fragment" + .to_string(), + )); + } + Ok(url) +} + fn ensure_git_repo_path( raw: impl Into, ) -> Result { @@ -184,7 +281,7 @@ impl Config { let bind_addr = parse_bind_addr(&bind_addr_raw)?; let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string()); @@ -192,6 +289,25 @@ impl Config { let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); + let pairing_relay_url = std::env::var("BUZZ_PAIRING_RELAY_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| { + let parsed = url::Url::parse(&value).map_err(|e| { + ConfigError::InvalidValue(format!( + "BUZZ_PAIRING_RELAY_URL must be a valid ws:// or wss:// URL: {e}" + )) + })?; + if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() { + return Err(ConfigError::InvalidValue( + "BUZZ_PAIRING_RELAY_URL must be a valid ws:// or wss:// URL".to_string(), + )); + } + Ok(value) + }) + .transpose()?; + let max_connections = std::env::var("BUZZ_MAX_CONNECTIONS") .ok() .and_then(|v| v.parse().ok()) @@ -236,6 +352,33 @@ impl Config { .map(|v| !(v == "false" || v == "0")) .unwrap_or(true); + // Mesh opt-in: default OFF. Strict rollout no-regression — an image + // upgrade with untouched env must not bind a new UDP port or write a + // new Redis key. Horizontally-scaled deployments explicitly set + // `BUZZ_MESH=on`; anything else (absent, `off`, other values) keeps + // exact single-instance behavior. + let mesh_enabled = std::env::var("BUZZ_MESH") + .map(|v| v.eq_ignore_ascii_case("on") || v == "true" || v == "1") + .unwrap_or(false); + let mesh_bind_addr = std::env::var("BUZZ_MESH_BIND_ADDR") + .map(|raw| { + raw.parse::().map_err(|e| { + ConfigError::InvalidValue(format!("invalid BUZZ_MESH_BIND_ADDR: {e}")) + }) + }) + .unwrap_or_else(|_| Ok("0.0.0.0:3478".parse().expect("static default parses")))?; + let mesh = buzz_relay_mesh::MeshConfig { + enabled: mesh_enabled, + bind_addr: mesh_bind_addr, + registry_refresh: std::time::Duration::from_secs(15), + }; + + // Demo echo opt-in: same strict pattern as BUZZ_MESH — explicit + // `on`/`true`/`1` only, anything else (absent, `off`, typos) is off. + let mesh_demo_echo = std::env::var("BUZZ_MESH_DEMO_ECHO") + .map(|v| v.eq_ignore_ascii_case("on") || v == "true" || v == "1") + .unwrap_or(false); + let allow_nip_oa_auth = std::env::var("BUZZ_ALLOW_NIP_OA_AUTH") .map(|v| v == "true" || v == "1") .unwrap_or(false); @@ -260,6 +403,46 @@ impl Config { } }); + // Note: intentionally not prefixed with BUZZ_ — same relay-identity + // config family as RELAY_OWNER_PUBKEY. Comma-separated 64-char hex + // pubkeys. Unlike RELAY_OWNER_PUBKEY (warn-and-ignore), an invalid + // entry here is a hard config error: silently dropping an operator + // pubkey would silently disable provisioning for that operator. + let relay_operator_api_origin = std::env::var("RELAY_OPERATOR_API_ORIGIN") + .ok() + .filter(|raw| !raw.trim().is_empty()) + .map(|raw| parse_operator_api_origin(&raw)) + .transpose()?; + + let relay_operator_pubkeys = match std::env::var("RELAY_OPERATOR_PUBKEYS") { + Ok(raw) => { + let mut pubkeys = Vec::new(); + for entry in raw.split(',') { + let entry = entry.trim().to_lowercase(); + if entry.is_empty() { + continue; + } + let valid = entry.len() == 64 && entry.chars().all(|c| c.is_ascii_hexdigit()); + if !valid { + return Err(ConfigError::InvalidValue(format!( + "RELAY_OPERATOR_PUBKEYS entry is not a valid 64-char hex pubkey: {entry:?}" + ))); + } + if !pubkeys.contains(&entry) { + pubkeys.push(entry); + } + } + pubkeys + } + Err(_) => Vec::new(), + }; + if !relay_operator_pubkeys.is_empty() && relay_operator_api_origin.is_none() { + return Err(ConfigError::InvalidValue( + "RELAY_OPERATOR_API_ORIGIN is required when RELAY_OPERATOR_PUBKEYS is configured" + .to_string(), + )); + } + let auth = buzz_auth::AuthConfig::default(); if !require_auth_token { @@ -322,6 +505,20 @@ impl Config { .unwrap_or(100 * 1024 * 1024), public_base_url: std::env::var("BUZZ_MEDIA_BASE_URL") .unwrap_or_else(|_| "http://localhost:3000/media".to_string()), + // Per-upload-event records (`_uploads/` moderation side channel). + // Off by default; coherence between the three knobs is enforced in + // MediaConfig::validate at startup. + upload_records_enabled: std::env::var("BUZZ_MEDIA_UPLOAD_RECORDS") + .map(|v| v == "true" || v == "1") + .unwrap_or(false), + upload_ip_header: std::env::var("BUZZ_MEDIA_UPLOAD_IP_HEADER") + .ok() + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()), + upload_port_header: std::env::var("BUZZ_MEDIA_UPLOAD_PORT_HEADER") + .ok() + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()), }; let media_max_concurrent_uploads: usize = std::env::var("BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS") @@ -342,6 +539,15 @@ impl Config { .filter(|&v| v > 0) .unwrap_or(30); + let require_media_get_auth = std::env::var("BUZZ_REQUIRE_MEDIA_GET_AUTH") + .map(|v| { + v == "true" + || v == "1" + || v.eq_ignore_ascii_case("yes") + || v.eq_ignore_ascii_case("on") + }) + .unwrap_or(false); + let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE") .ok() .and_then(|v| v.parse::().ok()) @@ -380,12 +586,44 @@ impl Config { let secret: [u8; 32] = rand::random(); hex::encode(secret) }); + let push_executor_key_id = + std::env::var("BUZZ_PUSH_EXECUTOR_KEY_ID").unwrap_or_else(|_| "relay-v1".to_string()); + if push_executor_key_id.is_empty() || push_executor_key_id.len() > 64 { + return Err(ConfigError::InvalidValue( + "BUZZ_PUSH_EXECUTOR_KEY_ID must contain 1..=64 bytes".to_string(), + )); + } + let push_gateway_delivery_url = match std::env::var("BUZZ_PUSH_GATEWAY_DELIVERY_URL") { + Ok(raw) if raw.trim().is_empty() => None, + Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?), + Err(_) => Some(parse_push_gateway_delivery_url( + DEFAULT_PUSH_GATEWAY_DELIVERY_URL, + )?), + }; + let push_gateway_timeout_millis = match std::env::var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS") { + Ok(raw) => raw + .parse::() + .ok() + .filter(|millis| (100..=10_000).contains(millis)) + .ok_or_else(|| { + ConfigError::InvalidValue( + "BUZZ_PUSH_GATEWAY_TIMEOUT_MS must be an integer in 100..=10000" + .to_string(), + ) + })?, + Err(_) => 2_000, + }; + let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis); + // Web UI static file serving let web_dir = std::env::var("BUZZ_WEB_DIR") .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .map(std::path::PathBuf::from); + let serve_git_web_gui = std::env::var("BUZZ_SERVE_GIT_WEB_GUI") + .map(|value| value == "true" || value == "1") + .unwrap_or(false); if let Some(ref dir) = web_dir { if !dir.join("index.html").is_file() { @@ -412,6 +650,7 @@ impl Config { database_url, redis_url, relay_url, + pairing_relay_url, max_connections, max_concurrent_handlers, send_buffer_size, @@ -427,12 +666,17 @@ impl Config { pubkey_allowlist_enabled, require_relay_membership, huddle_audio_available, + mesh, + mesh_demo_echo, relay_owner_pubkey, + relay_operator_api_origin, + relay_operator_pubkeys, allow_nip_oa_auth, media, media_max_concurrent_uploads, media_max_concurrent_uploads_per_pubkey, media_uploads_per_minute, + require_media_get_auth, ephemeral_ttl_override, git_repo_path, git_max_pack_bytes, @@ -440,7 +684,11 @@ impl Config { git_max_repos_per_pubkey, git_max_concurrent_ops, git_hook_hmac_secret, + push_executor_key_id, + push_gateway_delivery_url, + push_gateway_timeout, web_dir, + serve_git_web_gui, }) } } @@ -477,16 +725,162 @@ mod tests { config.relay_owner_pubkey.is_none(), "relay_owner_pubkey should default to None" ); + assert!( + config.relay_operator_pubkeys.is_empty(), + "relay_operator_pubkeys should default empty (provisioning disabled)" + ); assert!( !config.allow_nip_oa_auth, "allow_nip_oa_auth should default to false" ); + assert!( + !config.serve_git_web_gui, + "serve_git_web_gui should default to false" + ); + assert!( + !config.require_media_get_auth, + "require_media_get_auth should default to false for staged client rollout" + ); assert!( config.huddle_audio_available, "huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior" ); } + #[test] + fn relay_operator_pubkeys_parse_dedupe_and_normalize() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var( + "RELAY_OPERATOR_PUBKEYS", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + std::env::set_var( + "RELAY_OPERATOR_API_ORIGIN", + "http://buzz.mesh.bb-production.com", + ); + let config = Config::from_env().expect("config"); + std::env::remove_var("RELAY_OPERATOR_PUBKEYS"); + std::env::remove_var("RELAY_OPERATOR_API_ORIGIN"); + + assert_eq!( + config.relay_operator_pubkeys, + vec![ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + ] + ); + } + + #[test] + fn relay_operator_pubkeys_invalid_entry_is_error() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var("RELAY_OPERATOR_PUBKEYS", "not-a-pubkey"); + let result = Config::from_env(); + std::env::remove_var("RELAY_OPERATOR_PUBKEYS"); + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref msg)) if msg.contains("RELAY_OPERATOR_PUBKEYS") + )); + } + + #[test] + fn relay_operator_pubkeys_require_api_origin() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var( + "RELAY_OPERATOR_PUBKEYS", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + std::env::remove_var("RELAY_OPERATOR_API_ORIGIN"); + let result = Config::from_env(); + std::env::remove_var("RELAY_OPERATOR_PUBKEYS"); + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref msg)) if msg.contains("RELAY_OPERATOR_API_ORIGIN is required") + )); + } + + #[test] + fn relay_operator_api_origin_rejects_paths() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var("RELAY_OPERATOR_API_ORIGIN", "https://buzz.example/operator"); + let result = Config::from_env(); + std::env::remove_var("RELAY_OPERATOR_API_ORIGIN"); + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref msg)) if msg.contains("must be an http(s) origin") + )); + } + + #[test] + fn push_gateway_defaults_to_buzz_and_can_be_disabled() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); + std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); + let config = Config::from_env().expect("default config"); + assert_eq!( + config + .push_gateway_delivery_url + .as_ref() + .map(url::Url::as_str), + Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL) + ); + + std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", ""); + let config = Config::from_env().expect("disabled push config"); + assert!(config.push_gateway_delivery_url.is_none()); + + if let Some(value) = previous { + std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", value); + } else { + std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); + } + } + + #[test] + fn push_gateway_url_is_exact_and_fail_closed() { + assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok()); + for invalid in [ + "http://push.example/v1/deliveries/apns", + "https://push.example/v1/deliveries/apns/", + "https://push.example/v1/deliveries/apns?token=x", + "https://user@push.example/v1/deliveries/apns", + ] { + assert!( + parse_push_gateway_delivery_url(invalid).is_err(), + "{invalid}" + ); + } + } + + #[test] + fn invalid_push_gateway_timeout_is_not_silently_defaulted() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS", "99"); + let result = Config::from_env(); + std::env::remove_var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS"); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_PUSH_GATEWAY_TIMEOUT_MS") + )); + } + + #[test] + fn invalid_push_executor_key_id_is_rejected() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var("BUZZ_PUSH_EXECUTOR_KEY_ID", ""); + let result = Config::from_env(); + std::env::remove_var("BUZZ_PUSH_EXECUTOR_KEY_ID"); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_PUSH_EXECUTOR_KEY_ID") + )); + } + #[test] fn huddle_audio_available_can_be_disabled_for_horizontal_scaling() { let _guard = ENV_MUTEX.lock().unwrap(); @@ -507,6 +901,25 @@ mod tests { )); } + #[test] + fn pairing_relay_url_accepts_websocket_urls_and_rejects_http() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var("BUZZ_PAIRING_RELAY_URL", "wss://pairing.buzz.xyz"); + let config = Config::from_env().expect("config"); + assert_eq!( + config.pairing_relay_url.as_deref(), + Some("wss://pairing.buzz.xyz") + ); + + std::env::set_var("BUZZ_PAIRING_RELAY_URL", "https://pairing.buzz.xyz"); + let result = Config::from_env(); + std::env::remove_var("BUZZ_PAIRING_RELAY_URL"); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_PAIRING_RELAY_URL") + )); + } + #[test] fn max_frame_bytes_can_be_configured() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index cadbbf116c..5c53d53ef4 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -20,7 +20,7 @@ use nostr::Filter; use crate::handlers; use crate::protocol::{ClientMessage, RelayMessage}; -use crate::state::AppState; +use crate::state::{run_registered_community_connection, AppState}; use buzz_pubsub::EventTopic; /// Maximum time a new socket may hold a connection slot without completing NIP-42 auth. @@ -120,6 +120,31 @@ pub async fn handle_connection( state: Arc, addr: SocketAddr, tenant: TenantContext, +) { + let conn_id = Uuid::new_v4(); + let cancel = CancellationToken::new(); + let community_id = tenant.community(); + let registry = Arc::clone(&state.community_connections); + let check_state = Arc::clone(&state); + let run_state = Arc::clone(&state); + run_registered_community_connection( + ®istry, + conn_id, + community_id, + cancel.clone(), + move || async move { check_state.db.is_community_active(community_id).await }, + move || handle_active_connection(socket, run_state, addr, tenant, conn_id, cancel), + ) + .await; +} + +async fn handle_active_connection( + socket: WebSocket, + state: Arc, + addr: SocketAddr, + tenant: TenantContext, + conn_id: Uuid, + cancel: CancellationToken, ) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, @@ -129,9 +154,7 @@ pub async fn handle_connection( } }; - let conn_id = Uuid::new_v4(); let challenge = generate_challenge(); - let cancel = CancellationToken::new(); let (tx, rx) = mpsc::channel::(state.config.send_buffer_size); // Control channel for Pong/Close — small capacity, guaranteed delivery @@ -157,7 +180,11 @@ pub async fn handle_connection( }); info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); - metrics::counter!("buzz_ws_connections_total").increment(1); + metrics::counter!( + "buzz_ws_connections_total", + "community" => conn.tenant.host().to_owned() + ) + .increment(1); let challenge_msg = RelayMessage::auth_challenge(&challenge); if tx @@ -177,6 +204,7 @@ pub async fn handle_connection( state.conn_manager.register( conn_id, tx.clone(), + ctrl_tx.clone(), cancel.clone(), conn.tenant.community(), Arc::clone(&backpressure_count), @@ -242,9 +270,10 @@ pub async fn handle_connection( } state.conn_manager.deregister(conn.conn_id); if let AuthState::Authenticated(ref auth_ctx) = *conn.auth_state.read().await { - let remaining = state - .conn_manager - .connection_ids_for_pubkey(auth_ctx.pubkey.to_bytes().as_slice()); + let remaining = state.conn_manager.connection_ids_for_pubkey_in_community( + conn.tenant.community(), + auth_ctx.pubkey.to_bytes().as_slice(), + ); if remaining.is_empty() { let _ = state .pubsub @@ -294,6 +323,17 @@ async fn send_loop_inner( // so backpressure-triggered shutdown isn't starved by queued data. biased; _ = cancel.cancelled() => { + // Drain any queued control frames before closing. A ban + // disconnect queues its `OK false "blocked: …"` reason frame on + // ctrl and then cancels; without this drain the biased branch + // would send Close first and the client would never learn why + // (the top-of-loop drain does not run again after we break). + // This makes "queue frame on ctrl, then cancel" a safe idiom. + while let Ok(ctrl_msg) = ctrl_rx.try_recv() { + if ws_send.send(ctrl_msg).await.is_err() { + break; + } + } let _ = ws_send.send(WsMessage::Close(None)).await; break; } @@ -699,4 +739,43 @@ mod tests { vec!["control", "data-0", "data-1"] ); } + + #[tokio::test] + async fn send_loop_flushes_queued_control_before_close_on_cancel() { + // A ban disconnect queues its `OK false "blocked: …"` reason frame on + // the control channel and then cancels the token (B3). The biased + // select polls the cancel branch first, so the reason frame would be + // stranded unless the cancel branch drains ctrl before emitting Close. + // This test exercises `send_loop_inner` end-to-end to prove the reason + // frame reaches the client, in order, ahead of the Close. + let (_data_tx, data_rx) = mpsc::channel(1); + let (ctrl_tx, ctrl_rx) = mpsc::channel(1); + ctrl_tx + .send(WsMessage::Text("blocked: you are banned".into())) + .await + .expect("queue ban reason frame"); + + let cancel = CancellationToken::new(); + cancel.cancel(); + + let (sink, state) = MockSink::new(None); + send_loop_inner(sink, data_rx, ctrl_rx, cancel).await; + + let state = state.lock().expect("mock sink poisoned"); + assert_eq!( + state.messages.len(), + 2, + "reason frame then Close, nothing else" + ); + match &state.messages[0] { + WsMessage::Text(text) => { + assert_eq!(text.as_str(), "blocked: you are banned") + } + other => panic!("expected the ban reason frame first, got {other:?}"), + } + assert!( + matches!(state.messages[1], WsMessage::Close(_)), + "Close is sent only after the reason frame is flushed" + ); + } } diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index bc356979f5..dcb49df6a2 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -11,6 +11,7 @@ use std::sync::Arc; +use axum::extract::ws::Message as WsMessage; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; @@ -90,6 +91,98 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; + // Community ban gate (NIP-42 seam). Runs immediately after auth + // verification succeeds and before the allowlist and relay-membership + // gates, per COMMUNITY_MODERATION_PLAN.md §0 decision 4 and the + // MOD-7/M20 invariant (a ban must block connection auth even for open + // channels — enforcement is structural, not filtered later). A banned + // principal gets the standard protocol denial and the connection is + // dropped with zero further processing. + // + // NIP-OA cascade: a ban on the authenticated pubkey blocks it directly; + // a ban on its cryptographically-proven owner cascades to the agent + // (owner ban ⇒ agents banned; agent ban is agent-only). The owner is + // extracted from the self-proving auth tag with no DB round-trip. + { + // Fail closed on a DB error, but distinguish it from a real ban: + // a transient blip must deny (never let a banned principal + // through) without telling an innocent user they are banned and + // pinning `Failed` for the connection's life on a false premise. + // `Banned` claims the ban; `DbError` denies with `error: internal` + // (mirrors the ingest write-path gate). + enum BanOutcome { + Clear, + Banned, + DbError, + } + + let mut outcome = match state + .db + .moderation_restriction_state(conn.tenant.community(), pubkey.as_bytes()) + .await + { + Ok(state) if state.banned => BanOutcome::Banned, + Ok(_) => BanOutcome::Clear, + Err(e) => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, + "ban-state DB lookup failed, denying (fail-closed)"); + BanOutcome::DbError + } + }; + + // Cascade: check the proven NIP-OA owner only if the agent itself + // is clear (a DB error already denies; a direct ban already blocks + // — both skip the needless second DB read). + if matches!(outcome, BanOutcome::Clear) { + if let Some(owner) = crate::api::relay_members::extract_nip_oa_owner( + pubkey.as_bytes(), + auth_tag_json.as_deref(), + ) { + outcome = match state + .db + .moderation_restriction_state(conn.tenant.community(), owner.as_bytes()) + .await + { + Ok(state) if state.banned => BanOutcome::Banned, + Ok(_) => BanOutcome::Clear, + Err(e) => { + warn!(conn_id = %conn_id, owner = %owner.to_hex(), error = %e, + "owner ban-state DB lookup failed, denying (fail-closed)"); + BanOutcome::DbError + } + }; + } + } + + let denial: Option<(&str, &str)> = match outcome { + BanOutcome::Clear => None, + BanOutcome::Banned => { + Some(("banned", "blocked: you are banned from this community")) + } + BanOutcome::DbError => Some(( + "ban_check_error", + "error: internal error checking restriction state", + )), + }; + + if let Some((metric_reason, deny_reason)) = denial { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), reason = deny_reason, "principal denied at ban seam"); + metrics::counter!("buzz_auth_failures_total", "reason" => metric_reason) + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + // Decision 4: banned ⇒ OK false + immediate WebSocket close. + // Route the reason frame on the control channel (not `send`, + // which uses the data channel and would race the cancel), so + // the send loop drains it ahead of the Close it emits on + // cancel. Then cancel to close the socket immediately. + let _ = conn.ctrl_tx.try_send(WsMessage::Text( + RelayMessage::ok(&event_id_hex, false, deny_reason).into(), + )); + conn.cancel.cancel(); + return; + } + } + // Pubkey allowlist gate — only for pubkey-only auth. if state.config.pubkey_allowlist_enabled && auth_ctx.auth_method == buzz_auth::AuthMethod::Nip42 @@ -164,19 +257,39 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: if let Some(owner) = nip_oa_owner { // Ensure both agent and owner have users rows (BYO agents may not, // and agent_owner_pubkey has a FK constraint to users.pubkey). - if let Err(e) = state + match state .db .ensure_user(conn.tenant.community(), pubkey.as_bytes()) .await { - warn!(conn_id = %conn_id, error = %e, "ensure_user(agent) failed during NIP-OA backfill"); + Ok(true) => { + metrics::counter!( + "buzz_users_created_total", + "community" => conn.tenant.host().to_owned() + ) + .increment(1); + } + Ok(false) => {} + Err(e) => { + warn!(conn_id = %conn_id, error = %e, "ensure_user(agent) failed during NIP-OA backfill"); + } } - if let Err(e) = state + match state .db .ensure_user(conn.tenant.community(), owner.as_bytes()) .await { - warn!(conn_id = %conn_id, error = %e, "ensure_user(owner) failed during NIP-OA backfill"); + Ok(true) => { + metrics::counter!( + "buzz_users_created_total", + "community" => conn.tenant.host().to_owned() + ) + .increment(1); + } + Ok(false) => {} + Err(e) => { + warn!(conn_id = %conn_id, error = %e, "ensure_user(owner) failed during NIP-OA backfill"); + } } // Idempotent backfill: record agent→owner in DB so cross-connection diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index e3b19770b6..bd3eff4de9 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -42,12 +42,22 @@ pub async fn handle_command( // Ensure the authenticated user exists in the users table (foreign key requirement). // The old REST handlers did this via extract_auth_context; command executor must do it explicitly. let pubkey_bytes = auth.pubkey().to_bytes().to_vec(); - if let Err(e) = state + match state .db .ensure_user(tenant.community(), &pubkey_bytes) .await { - tracing::warn!("command_executor: ensure_user failed: {e}"); + Ok(true) => { + metrics::counter!( + "buzz_users_created_total", + "community" => tenant.host().to_owned() + ) + .increment(1); + } + Ok(false) => {} + Err(e) => { + tracing::warn!("command_executor: ensure_user failed: {e}"); + } } let kind = event.kind.as_u16() as u32; @@ -362,6 +372,13 @@ async fn handle_dm_open( // 5. Side effects if newly created (post-commit, best-effort) if was_created { + metrics::counter!( + "buzz_channels_created_total", + "community" => tenant.host().to_owned(), + "type" => "dm" + ) + .increment(1); + // Invalidate caches for all participants for pk in &all_bytes { state.invalidate_membership(tenant, channel.id, pk); @@ -516,6 +533,13 @@ async fn handle_dm_add_member( // 7. Cache invalidation + notifications for new DM (post-commit, best-effort) if was_created { + metrics::counter!( + "buzz_channels_created_total", + "community" => tenant.host().to_owned(), + "type" => "dm" + ) + .increment(1); + for pk in &all_bytes { state.invalidate_membership(tenant, new_channel.id, pk); } diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs new file mode 100644 index 0000000000..3185af8bea --- /dev/null +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -0,0 +1,445 @@ +//! Relay-operator community provisioning HTTP handler support. +//! +//! ## Authorization: operator, not owner +//! +//! Every other admin surface in this relay is community-scoped — the sender's +//! role is looked up in `relay_members (community_id, pubkey)` for the +//! host-resolved tenant. Community *creation* cannot work that way: its effect +//! is the creation of tenancy itself, so the authorizing identity must sit +//! above tenants. The gate here is the deployment-level +//! `RELAY_OPERATOR_PUBKEYS` allowlist (see `Config::relay_operator_pubkeys`). +//! An empty allowlist (the default) disables provisioning entirely. +//! +//! The public surface is `POST /operator/communities`, authenticated by NIP-98 +//! and gated by the deployment-level `RELAY_OPERATOR_PUBKEYS` allowlist. The +//! endpoint is intentionally outside the Nostr event ingest data plane: no +//! relay-membership bypass, no special event kind, no storage or fan-out. +//! +//! ## Request shape +//! +//! ```json +//! { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } +//! ``` +//! +//! `initial_owner_pubkey` is optional. When present for an existing community, +//! it rotates that community owner through the same bootstrap path used by +//! `RELAY_OWNER_PUBKEY`; relay operators are deployment-root authorities. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +use buzz_core::tenant::{normalize_host, TenantContext}; +use url::{Host, Url}; + +use crate::state::AppState; + +/// Maximum accepted authority length. Matches `communities.host VARCHAR(255)`. +const MAX_HOST_LEN: usize = 255; + +/// JSON body for `POST /operator/communities`. +#[derive(Debug, Deserialize)] +pub struct ProvisionCommunityRequest { + /// Normalized authority for the community to ensure/create. + pub host: String, + /// Optional initial owner pubkey. When set on an existing community this + /// rotates the owner through the same bootstrap path used at startup. + #[serde(default)] + pub initial_owner_pubkey: Option, + /// When true, atomically create the host and owner and reject an existing + /// host instead of converging or rotating ownership. + #[serde(default)] + pub create_only: bool, +} + +/// JSON response from `POST /operator/communities`. +#[derive(Debug, Serialize)] +pub struct ProvisionCommunityResponse { + /// UUID of the ensured/created community. + pub community_id: String, + /// Canonical host stored on the community row. + pub host: String, + /// `created` when the host row was inserted, `existed` when it was already + /// present and the request converged idempotently. + pub status: &'static str, + /// Echoes the validated owner pubkey when an owner bootstrap/rotation ran. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_pubkey: Option, +} + +pub(crate) fn validate_pubkey_hex(value: &str) -> Option { + let normalized = value.to_ascii_lowercase(); + (normalized.len() == 64 && normalized.chars().all(|c| c.is_ascii_hexdigit())) + .then_some(normalized) +} + +/// Validate a normalized host authority value for a community. +/// +/// The host must already be in normalized shape (`normalize_host` is a +/// no-op on it): lowercase, no default port, no trailing dot. Requiring the +/// caller to send the normalized form keeps the stored `communities.host` +/// value byte-identical to what request-time host resolution will look up. +fn validate_host(host: &str) -> Result<(), String> { + if host.is_empty() { + return Err("host is empty".to_string()); + } + if host.len() > MAX_HOST_LEN { + return Err(format!( + "host too long: {} bytes (max {MAX_HOST_LEN})", + host.len() + )); + } + if normalize_host(host) != host { + return Err(format!( + "host is not normalized: expected {:?}", + normalize_host(host) + )); + } + validate_authority(host) +} + +fn validate_authority(authority: &str) -> Result<(), String> { + if authority + .chars() + .any(|c| c.is_control() || c.is_whitespace()) + { + return Err("host contains invalid characters".to_string()); + } + if authority.contains('/') + || authority.contains('?') + || authority.contains('#') + || authority.contains('@') + { + return Err( + "host must be a bare authority (no scheme, path, query, or userinfo)".to_string(), + ); + } + + // Parse as an HTTP authority by wrapping it in a URL. This rejects empty + // hosts, malformed bracketed IPv6 literals, and invalid ports while keeping + // the accepted shape aligned with request `Host` authority syntax. + let parsed = Url::parse(&format!("http://{authority}/")) + .map_err(|_| "host is not a valid authority".to_string())?; + let host = parsed + .host() + .ok_or_else(|| "host is not a valid authority".to_string())?; + + let serialized_host = match host { + Host::Domain(domain) => { + validate_domain_labels(domain)?; + domain.to_string() + } + Host::Ipv4(addr) => addr.to_string(), + Host::Ipv6(addr) => format!("[{addr}]"), + }; + let canonical_authority = match parsed.port() { + Some(port) => format!("{serialized_host}:{port}"), + None => serialized_host, + }; + + if canonical_authority != authority { + return Err(format!( + "host is not a canonical authority: expected {canonical_authority:?}" + )); + } + + Ok(()) +} + +fn validate_domain_labels(domain: &str) -> Result<(), String> { + if domain.len() > 253 { + return Err("domain name too long".to_string()); + } + for label in domain.split('.') { + if label.is_empty() { + return Err("domain contains an empty label".to_string()); + } + if label.len() > 63 { + return Err("domain label too long".to_string()); + } + let valid_label = label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + && !label.starts_with('-') + && !label.ends_with('-'); + if !valid_label { + return Err("domain label contains invalid characters".to_string()); + } + } + Ok(()) +} + +/// Normalize and validate a host supplied to read-only operator endpoints. +/// +/// Unlike create, availability checks may accept non-canonical but normalizable +/// authority values (uppercase host, trailing dot, default port) so kgoose can +/// ask the relay for the canonical spelling before creating. Schemes, paths, +/// userinfo, whitespace/control characters, and oversized values are still +/// rejected. +pub(crate) fn normalize_candidate_host(host: &str) -> Result { + if host.is_empty() { + return Err("host is empty".to_string()); + } + if host.len() > MAX_HOST_LEN { + return Err(format!( + "host too long: {} bytes (max {MAX_HOST_LEN})", + host.len() + )); + } + if host.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err("host contains invalid characters".to_string()); + } + if host.contains('/') || host.contains('?') || host.contains('#') || host.contains('@') { + return Err( + "host must be a bare authority (no scheme, path, query, or userinfo)".to_string(), + ); + } + + let normalized = normalize_host(host); + validate_host(&normalized)?; + Ok(normalized) +} + +/// Publish the event-backed membership view after owner persistence. +/// +/// Provisioning is already committed when this runs. Publication therefore +/// remains best-effort, matching every other membership mutation path: turning +/// a stored success into an HTTP failure would make create-only retries report +/// a misleading conflict while leaving clients without a repair path. +async fn publish_membership_snapshot_if_required( + state: &Arc, + community: buzz_core::CommunityId, + host: &str, +) { + if !state.config.require_relay_membership { + return; + } + + let tenant = TenantContext::resolved(community, host); + if let Err(error) = + crate::handlers::side_effects::publish_nip43_membership_list(&tenant, state).await + { + warn!( + community = %community, + host, + error = %error, + "community provisioned but NIP-43 membership snapshot publication failed" + ); + } +} + +/// Validate and execute a relay-operator community provisioning request. +/// +/// The caller is an HTTP operator endpoint, not the Nostr event ingest path. +/// That keeps the tenant data-plane fences unchanged: no relay-membership +/// bypass, no special event kind, no command routed ahead of moderation/write +/// blocks. The endpoint authenticates its NIP-98 signer first, then passes the +/// signer here for the deployment-level `RELAY_OPERATOR_PUBKEYS` allowlist. +/// +/// Idempotency and owner semantics: the request is idempotent on the host row +/// (re-sending it never duplicates a community). When `initial_owner_pubkey` is +/// present, the owner is (re)bootstrapped via [`buzz_db::Db::bootstrap_owner`] +/// even if the community already existed — any previous owner is demoted to +/// admin, exactly like rotating `RELAY_OWNER_PUBKEY` for the deployment +/// community. This makes a retry after a partial failure (row created, owner +/// bootstrap crashed) converge, at the cost that an operator-signed request can +/// rotate an existing community's owner. The operator allowlist is therefore +/// documented as deployment-root authority, not create-only authority. +pub async fn provision_community( + state: &Arc, + operator_pubkey: &nostr::PublicKey, + request: ProvisionCommunityRequest, +) -> Result { + let operator_hex = operator_pubkey.to_hex(); + + // Operator gate. Deliberately NOT a relay_members lookup: provisioning + // authority spans tenants and lives in deployment config only. Empty + // allowlist → everyone is rejected (fail closed). + if !state + .config + .relay_operator_pubkeys + .iter() + .any(|pk| pk == &operator_hex) + { + return Err("actor not authorized: not a relay operator".to_string()); + } + + validate_host(&request.host)?; + + let initial_owner = request + .initial_owner_pubkey + .as_deref() + .map(|value| { + validate_pubkey_hex(value).ok_or_else(|| { + "invalid initial_owner_pubkey: expected 64-char hex pubkey".to_string() + }) + }) + .transpose()?; + + if request.create_only { + let owner_hex = initial_owner.as_deref().ok_or_else(|| { + "initial_owner_pubkey is required when create_only is true".to_string() + })?; + let record = match state + .db + .create_community_with_owner(&request.host, owner_hex) + .await + .map_err(|e| format!("failed to create community: {e}"))? + { + buzz_db::CreateCommunityWithOwnerResult::Created(record) => record, + buzz_db::CreateCommunityWithOwnerResult::HostExists => { + return Err("community already exists".to_string()); + } + buzz_db::CreateCommunityWithOwnerResult::LimitReached => { + return Err( + "limit_reached: owner already owns the maximum number of communities" + .to_string(), + ); + } + }; + + info!( + operator = %operator_hex, + community = %record.id, + host = %record.host, + owner = %owner_hex, + "community created via operator endpoint" + ); + publish_membership_snapshot_if_required(state, record.id, &record.host).await; + return Ok(ProvisionCommunityResponse { + community_id: record.id.to_string(), + host: record.host, + status: "created", + owner_pubkey: initial_owner, + }); + } + + // Legacy convergence mode remains available to deployment operators and + // startup tooling. Clients provisioning on behalf of end users must use + // create_only so an existing owner can never be rotated by a create race. + let record = state + .db + .ensure_configured_community(&request.host) + .await + .map_err(|e| format!("failed to create community: {e}"))?; + + if let Some(owner_hex) = &initial_owner { + state + .db + .bootstrap_owner(record.id, owner_hex) + .await + .map_err(|e| format!("community provisioned but owner bootstrap failed: {e}"))?; + publish_membership_snapshot_if_required(state, record.id, &record.host).await; + } + + info!( + operator = %operator_hex, + community = %record.id, + host = %record.host, + owner = initial_owner.as_deref().unwrap_or(""), + created = record.created, + "community provisioned via operator endpoint" + ); + + Ok(ProvisionCommunityResponse { + community_id: record.id.to_string(), + host: record.host, + status: if record.created { "created" } else { "existed" }, + owner_pubkey: initial_owner, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_valid_bare_domain() { + assert!(validate_host("acme.communities.buzz.xyz").is_ok()); + } + + #[test] + fn host_valid_with_port() { + assert!(validate_host("localhost:3000").is_ok()); + } + + #[test] + fn host_rejects_empty() { + assert!(validate_host("").is_err()); + } + + #[test] + fn host_rejects_uppercase() { + assert!(validate_host("Acme.example").is_err()); + } + + #[test] + fn host_rejects_default_port() { + assert!(validate_host("acme.example:443").is_err()); + assert!(validate_host("acme.example:80").is_err()); + } + + #[test] + fn host_rejects_trailing_dot() { + assert!(validate_host("acme.example.").is_err()); + } + + #[test] + fn host_rejects_scheme_path_userinfo() { + assert!(validate_host("wss://acme.example").is_err()); + assert!(validate_host("acme.example/path").is_err()); + assert!(validate_host("user@acme.example").is_err()); + assert!(validate_host("acme.example?x=1").is_err()); + assert!(validate_host("acme.example#frag").is_err()); + } + + #[test] + fn host_rejects_invalid_authorities() { + assert!(validate_host(":").is_err()); + assert!(validate_host("example..com").is_err()); + assert!(validate_host("foo_bar.example").is_err()); + assert!(validate_host("-bad.example").is_err()); + assert!(validate_host("bad-.example").is_err()); + assert!(validate_host("example.com:99999").is_err()); + assert!(validate_host("[::1").is_err()); + assert!(validate_host("[not-ipv6]").is_err()); + assert!(validate_host(&format!("{}.example", "a".repeat(64))).is_err()); + } + + #[test] + fn host_rejects_whitespace_and_control() { + assert!(validate_host("acme .example").is_err()); + assert!(validate_host("acme\n.example").is_err()); + } + + #[test] + fn host_rejects_oversized() { + let long = format!("{}.example", "a".repeat(260)); + assert!(validate_host(&long).is_err()); + } + + #[test] + fn host_accepts_ipv6_bracket_literal() { + assert!(validate_host("[::1]:3000").is_ok()); + } + + #[test] + fn candidate_host_normalizes_safe_variants() { + assert_eq!( + normalize_candidate_host("Acme.Example:443").unwrap(), + "acme.example" + ); + assert_eq!( + normalize_candidate_host("acme.example.").unwrap(), + "acme.example" + ); + } + + #[test] + fn candidate_host_rejects_non_authorities() { + assert!(normalize_candidate_host("https://acme.example").is_err()); + assert!(normalize_candidate_host("acme.example/path").is_err()); + assert!(normalize_candidate_host("acme .example").is_err()); + } +} diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ad26fe5f2e..169615f0b5 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -8,7 +8,7 @@ use tracing::{debug, error, info, warn}; use buzz_core::event::StoredEvent; use buzz_core::kind::{ event_kind_u32, is_ephemeral, AUTHOR_ONLY_KINDS, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, - KIND_MESH_CONNECT_REQUEST, KIND_MESH_STATUS_REPORT, KIND_PRESENCE_UPDATE, + KIND_PRESENCE_UPDATE, }; use buzz_core::observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -43,6 +43,7 @@ fn bounded_kind_label(kind: u32) -> String { 41001 | 41010..=41012 => kind.to_string(), 43001..=43006 => kind.to_string(), 44100..=44101 => kind.to_string(), + 44200 => kind.to_string(), 45001..=45003 => kind.to_string(), 46001..=46012 | 46020 | 46030..=46031 => kind.to_string(), 48001 | 48100..=48103 | 48106 => kind.to_string(), @@ -509,6 +510,7 @@ async fn dispatch_persistent_event_inner( let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); let trigger_kind = kind_u32.to_string(); + let workflow_community_host = tenant.host().to_owned(); // The event was stored under `tenant.community()`; `StoredEvent` does // not carry the community, so pass it explicitly. The same channel UUID // can exist in another community — scoping the workflow lookup to this @@ -522,8 +524,12 @@ async fn dispatch_persistent_event_inner( { tracing::error!(event_id = ?workflow_event.event.id, "Workflow trigger failed: {e}"); } else { - metrics::counter!("buzz_workflow_runs_total", "trigger" => trigger_kind) - .increment(1); + metrics::counter!( + "buzz_workflow_runs_total", + "trigger" => trigger_kind, + "community" => workflow_community_host + ) + .increment(1); } }); } @@ -585,7 +591,19 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc kind_str.clone()).increment(1); + // Per-community volume counter: community-only, no kind tag. + // Use this for per-community throughput graphs; the fleet counter above + // for per-kind breakdowns. + metrics::counter!( + "buzz_community_events_received_total", + "community" => conn.tenant.host().to_owned() + ) + .increment(1); let (conn_id, pubkey_bytes, auth_pubkey, scopes, channel_ids) = { let auth = conn.auth_state.read().await; @@ -684,6 +702,8 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { if result.accepted { + // Fleet-wide stored counter: kind-only, no community tag. + // Same cardinality rationale as buzz_events_received_total above. metrics::counter!("buzz_events_stored_total", "kind" => kind_str).increment(1); info!( event_id = %result.event_id, @@ -782,66 +802,6 @@ async fn handle_ephemeral_event( // publish/fan-out path below so other relay nodes receive the live delta. } - // Mesh status report (kind:24620). An authenticated relay member reports its - // current mesh serve availability; the relay projects it into a relay-signed, - // per-reporter kind:30621 discovery note. The report is ephemeral input; the - // 30621 is the durable, relay-owned record. - if event_kind_u32(&event) == KIND_MESH_STATUS_REPORT { - let reporter_hex = auth_pubkey.to_hex(); - match super::mesh_signaling::handle_status_report( - &state, - &conn.tenant, - &reporter_hex, - &event, - ) - .await - { - Ok(()) => { - conn.send(RelayMessage::ok(event_id_hex, true, "")); - } - Err(reason) => { - conn.send(RelayMessage::ok(event_id_hex, false, &reason)); - } - } - return; - } - - // Mesh hole-punch signaling (kind:24621). An authenticated relay member - // asks the relay to coordinate a direct iroh hole-punch to a peer it found - // via kind:30621. The relay validates the target is also a member, then - // emits the paired call-me-now (kind:24622). This is the relay's ONLY role - // in the v1 direct-iroh mesh — validate membership + pair + fan out. It - // never carries iroh traffic and stores no endpoint state. - if event_kind_u32(&event) == KIND_MESH_CONNECT_REQUEST { - // Per-requester rate limit shared with the HTTP door — see - // `mesh_signaling::connect_request_rate_limited` for rationale. - if super::mesh_signaling::connect_request_rate_limited(&state, &auth_pubkey) { - conn.send(RelayMessage::ok( - event_id_hex, - false, - "rate-limited: mesh connect request rate exceeded (20/sec)", - )); - return; - } - let requester_hex = auth_pubkey.to_hex(); - match super::mesh_signaling::handle_connect_request( - &state, - &conn.tenant, - &requester_hex, - &event, - ) - .await - { - Ok(()) => { - conn.send(RelayMessage::ok(event_id_hex, true, "")); - } - Err(reason) => { - conn.send(RelayMessage::ok(event_id_hex, false, &reason)); - } - } - return; - } - // Check channel membership before publishing other ephemeral events. if let Some(ch_id) = super::ingest::extract_channel_id(&event) { if let Err(msg) = super::ingest::check_channel_membership( @@ -924,6 +884,32 @@ struct AgentObserverRoute { direction: AgentObserverDirection, } +/// Check + bump the per-agent observer telemetry limit (100/sec window). +/// +/// Observer frames are ephemeral, but the rejection is visible to the sender. +/// Scope the counter by community so an agent key active in one tenant does not +/// consume another tenant's logical rate budget. +fn observer_frame_rate_limited( + state: &AppState, + community_id: CommunityId, + agent_key: [u8; 32], +) -> bool { + let now = std::time::Instant::now(); + let mut entry = state + .observer_rate_limiter + .entry((community_id, agent_key)) + .or_insert((0, now)); + let (count, window_start) = entry.value_mut(); + if now.duration_since(*window_start).as_secs() >= 1 { + *count = 1; + *window_start = now; + false + } else { + *count += 1; + *count > 100 + } +} + /// Handle encrypted agent observer frames (kind 24200). /// /// These frames bypass storage and are routed as global ephemeral events. The @@ -1041,25 +1027,13 @@ async fn handle_agent_observer_event( // be starved by bursty telemetry from the agent. if matches!(route.direction, AgentObserverDirection::Telemetry) { let agent_key: [u8; 32] = agent_bytes.as_slice().try_into().unwrap_or([0u8; 32]); - let now = std::time::Instant::now(); - let mut entry = state - .observer_rate_limiter - .entry(agent_key) - .or_insert((0, now)); - let (count, window_start) = entry.value_mut(); - if now.duration_since(*window_start).as_secs() >= 1 { - *count = 1; - *window_start = now; - } else { - *count += 1; - if *count > 100 { - conn.send(RelayMessage::ok( - event_id_hex, - false, - "rate-limited: observer frame rate exceeded (100/sec per agent)", - )); - return; - } + if observer_frame_rate_limited(&state, conn.tenant.community(), agent_key) { + conn.send(RelayMessage::ok( + event_id_hex, + false, + "rate-limited: observer frame rate exceeded (100/sec per agent)", + )); + return; } } @@ -1304,6 +1278,31 @@ mod tests { assert!(err.contains("NIP-44")); } + #[tokio::test] + async fn observer_frame_rate_limiter_is_scoped_by_community() { + let state = fanout_access::test_state().await; + let agent_key = Keys::generate().public_key().to_bytes(); + let community_a = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::from_u128(0xAAAA)); + let community_b = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::from_u128(0xBBBB)); + + for _ in 0..100 { + assert!(!super::observer_frame_rate_limited( + &state, + community_a, + agent_key + )); + } + assert!(super::observer_frame_rate_limited( + &state, + community_a, + agent_key + )); + assert!( + !super::observer_frame_rate_limited(&state, community_b, agent_key), + "A's exhausted budget must not rate-limit the same agent key in B" + ); + } + mod pubsub_fanout { use std::collections::HashMap; use std::sync::atomic::AtomicU8; @@ -1332,9 +1331,11 @@ mod tests { ) -> (Uuid, mpsc::Receiver) { let conn_id = Uuid::new_v4(); let (tx, rx) = mpsc::channel(10); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); state.conn_manager.register( conn_id, tx, + ctrl_tx, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -1969,9 +1970,11 @@ mod tests { fn register_conn(state: &AppState, pubkey: Option>) -> Uuid { let conn_id = Uuid::new_v4(); let (tx, _rx) = mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); state.conn_manager.register( conn_id, tx, + ctrl_tx, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -2292,9 +2295,11 @@ mod tests { ) -> Uuid { let conn_id = Uuid::new_v4(); let (tx, _rx) = mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); state.conn_manager.register( conn_id, tx, + ctrl_tx, CancellationToken::new(), community_id, Arc::new(AtomicU8::new(0)), diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9abb5948f9..cd33118406 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -22,16 +22,17 @@ use buzz_core::kind::{ KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MUTE_LIST, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, + KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, - KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, - KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, - KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, + KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; @@ -124,6 +125,22 @@ impl IngestAuth { } } +fn emit_product_feedback_success( + tracer: &Arc, + tenant: &TenantContext, + event: &Event, + auth: &IngestAuth, +) { + emit( + tracer, + TraceAction::WriteInsertGlobal { + msg_id: msg_id_label(event.id.as_bytes()), + claimed_community: claimed_community_from_event(event), + }, + state_for_request(tenant, auth.pubkey()), + ); +} + /// Successful ingestion result. pub struct IngestResult { /// Hex-encoded event ID. @@ -145,6 +162,15 @@ pub enum IngestError { Internal(String), } +fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { + match error { + super::push_lease::AcceptError::Validation(reason) => { + IngestError::Rejected(format!("invalid: {reason}")) + } + super::push_lease::AcceptError::Internal(reason) => IngestError::Internal(reason), + } +} + /// Determine the required scope for a given event kind. /// /// Returns `Err` for unknown kinds — the relay rejects them. @@ -153,11 +179,20 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM - | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT => { + | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT + | super::push_lease::KIND_PUSH_LEASE => { Ok(Scope::UsersWrite) } // NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner). KIND_AGENT_TURN_METRIC => Ok(Scope::MessagesWrite), + // NIP-56 reports are ordinary member writes into the mod-only queue. + // Ingest persists them to `moderation_reports` and suppresses public + // storage/fanout; reports are signals, never enforcement triggers. + KIND_REPORT | KIND_PRODUCT_FEEDBACK => Ok(Scope::MessagesWrite), + // Community moderation commands are direct, mod-authz-gated writes. + // Scope only proves the transport can submit message writes; the + // command handler owns role/capability authorization. + k if buzz_core::kind::is_moderation_command_kind(k) => Ok(Scope::MessagesWrite), // NIP-51 standard lists and NIP-65 relay list — user-owned global state, // same ownership shape as kind:3 (contacts) and kind:0 (profile). KIND_MUTE_LIST @@ -191,7 +226,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result bool { | KIND_GIT_STATUS_MERGED | KIND_GIT_STATUS_CLOSED | KIND_GIT_STATUS_DRAFT + // Community moderation commands (9040–9044): community-global + // direct commands, same model as the NIP-43 9030-series. A stray + // `h` tag must never channel-scope them (pinned contract — + // handlers/moderation_commands.rs routing docs). + | KIND_MODERATION_BAN + | KIND_MODERATION_UNBAN + | KIND_MODERATION_TIMEOUT + | KIND_MODERATION_UNTIMEOUT + | KIND_MODERATION_RESOLVE_REPORT // NIP-43: relay admin commands and leave requests are global — they // must never be channel-scoped, even if the event carries a stray `h` tag. | RELAY_ADMIN_ADD_MEMBER @@ -378,12 +422,11 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // events. A stray `h` tag must not channel-scope them. | KIND_IA_ARCHIVE_REQUEST | KIND_IA_UNARCHIVE_REQUEST - // Mesh-LLM relay status is relay-signed and global. Clients may - // subscribe to it, but must not channel-scope or submit it. - | KIND_MESH_LLM_RELAY_STATUS // NIP-AM: agent turn metrics are owner-scoped global events. // Channel identity is encrypted inside the payload — no `h` tag. | KIND_AGENT_TURN_METRIC + // NIP-PL leases are author-owned, addressable global state. + | super::push_lease::KIND_PUSH_LEASE ) } @@ -1415,6 +1458,111 @@ async fn ingest_event_inner( return super::command_executor::handle_command(tenant, state, event, auth).await; } + // Product feedback is sidecarred directly into its private deployment table. + // It never enters ordinary event storage or subscription fan-out. + if kind_u32 == KIND_PRODUCT_FEEDBACK { + super::product_feedback::handle(tenant, &event, state) + .await + .map_err(IngestError::Rejected)?; + // Feedback is a host-resolved, channel-less write. Although its row is + // private to operator tooling rather than ordinary event reads, this is + // the matching modeled success action at the ingest isolation seam. + emit_product_feedback_success(tracer, tenant, &event, &auth); + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message: String::new(), + }); + } + + // NIP-56 reports are persisted only to the mod queue. They are not stored in + // the public events table and never fan out to subscribers. Reports remain + // available while timed out so users can signal abuse during a write-block. + // A banned actor in the rare missed-disconnect window may also submit a + // report; that is tolerated because reports are non-actioning signals and + // remain visible only to moderators. + if kind_u32 == KIND_REPORT { + super::report::handle_report_event(tenant, &event, state) + .await + .map_err(IngestError::Rejected)?; + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message: String::new(), + }); + } + + // Community moderation commands (9040–9044) are direct, community-global + // mutations. They are never stored or fanned out as ordinary events; the + // handler writes the durable audit/enforcement rows after its own capability + // authorization. These commands are intentionally routed before the + // timeout/write-block gate below: restriction-lifting commands must remain + // available so a wrongly restricted admin is not stranded, while banned + // actors are handled by the auth seam and live-disconnect enforcement. + if buzz_core::kind::is_moderation_command_kind(kind_u32) { + super::moderation_commands::handle_moderation_command(tenant, state, &event) + .await + .map_err(IngestError::Rejected)?; + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message: String::new(), + }); + } + + // Community ban / timeout write-block (COMMUNITY_MODERATION_PLAN.md §0 + // decision 4). A timeout is a write-block only — the connection stays open, + // content writes are refused with `restricted: you are timed out until ` + // so the desktop can render a countdown. A ban is normally enforced at the + // auth seam, but an already-authenticated connection never re-auths: if the + // live-disconnect fan-out is missed (fire-and-forget publish, broadcast lag, + // subscriber reconnect window), a banned member's open socket would keep + // writing indefinitely. So the ban is re-checked here — this write-path gate + // is the durable backstop the fan-out's best-effort delivery relies on. + // Moderation/relay-admin commands are exempt: a restriction must never + // disarm the tools used to lift or manage it. + // + // Scope: this gate checks the *authoring* pubkey only, with no NIP-OA + // owner→agent cascade. That cascade lives at the auth seam for bans, where + // it is structural: an agent whose owner is banned can never authenticate, + // so its socket never exists to reach ingest. Timeout has no auth-seam + // presence (it is write-block-only), so an owner-timeout does not cascade to + // the owner's agents — a deliberate Phase-1 asymmetry. `IngestAuth` does not + // carry the self-proving auth tag, so resolving the owner here would mean + // plumbing it through the whole transport boundary; the follow-up shape is + // the restriction-state cache (see should-fix), which can fold in owner + // resolution without a per-write DB round-trip. + if !buzz_core::kind::is_moderation_command_kind(kind_u32) && !is_relay_admin_kind(kind_u32) { + match state + .db + .moderation_restriction_state(tenant.community(), auth.pubkey().as_bytes()) + .await + { + Ok(r) => { + if r.banned { + return Err(IngestError::AuthFailed( + "blocked: you are banned from this community".to_string(), + )); + } + if let Some(until) = r.muted_until { + if until > chrono::Utc::now() { + return Err(IngestError::AuthFailed(format!( + "restricted: you are timed out until {}", + until.timestamp() + ))); + } + } + } + Err(e) => { + // Fail closed: a DB error must not let a banned/timed-out actor + // write. + return Err(IngestError::Internal(format!( + "error: internal error checking restriction state: {e}" + ))); + } + } + } + let mut channel_id = if kind_u32 == KIND_REACTION { match derive_reaction_channel(tenant.community(), &state.db, &event).await { ReactionChannelResult::Channel(ch_id) => Some(ch_id), @@ -1891,6 +2039,12 @@ async fn ingest_event_inner( }); } pre_created_channel = Some(client_uuid); + metrics::counter!( + "buzz_channels_created_total", + "community" => tenant.host().to_owned(), + "type" => channel_type.to_string() + ) + .increment(1); } } @@ -1916,6 +2070,54 @@ async fn ingest_event_inner( } } + if kind_u32 == super::push_lease::KIND_PUSH_LEASE { + let outcome = super::push_lease::accept(tenant, state, &event, now) + .await + .map_err(map_push_accept_error)?; + match outcome { + buzz_db::push::AcceptLeaseOutcome::Accepted => {} + buzz_db::push::AcceptLeaseOutcome::StaleEvent => { + return Err(IngestError::Rejected("invalid: stale replacement".into())); + } + buzz_db::push::AcceptLeaseOutcome::StaleGeneration => { + return Err(IngestError::Rejected("invalid: stale generation".into())); + } + buzz_db::push::AcceptLeaseOutcome::EndpointAlreadyLeased => { + return Err(IngestError::Rejected( + "invalid: endpoint already leased".into(), + )); + } + buzz_db::push::AcceptLeaseOutcome::LeaseQuotaExceeded => { + return Err(IngestError::Rejected( + "invalid: lease quota exceeded".into(), + )); + } + buzz_db::push::AcceptLeaseOutcome::SourceEventCollision => { + return Err(IngestError::Rejected( + "invalid: source event collision".into(), + )); + } + buzz_db::push::AcceptLeaseOutcome::ConstraintViolation => { + return Err(IngestError::Rejected( + "invalid: lease constraint violation".into(), + )); + } + }; + emit( + tracer, + TraceAction::WriteInsertGlobal { + msg_id: msg_id_label(event.id.as_bytes()), + claimed_community: claimed_community_from_event(&event), + }, + state_for_request(tenant, auth.pubkey()), + ); + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message: String::new(), + }); + } + let imeta_tags: Vec> = event .tags .iter() @@ -2230,12 +2432,64 @@ async fn ingest_event_inner( #[cfg(test)] mod tests { + use std::sync::Mutex; + use super::*; + use buzz_conformance::{TraceStep, Tracer}; use buzz_core::kind::{ KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, KIND_TEAM, KIND_USER_STATUS, }; + use nostr::{EventBuilder, Kind}; + + #[derive(Debug, Default)] + struct VecTracer { + steps: Mutex>, + } + + impl Tracer for VecTracer { + fn record(&self, step: TraceStep) { + self.steps.lock().expect("trace lock").push(step); + } + } + + #[test] + fn feedback_success_action_satisfies_ingest_emit_guard() { + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let tenant = TenantContext::resolved(community, "feedback.test"); + let keys = nostr::Keys::generate(); + let event = EventBuilder::new( + Kind::Custom(KIND_PRODUCT_FEEDBACK as u16), + "Useful feedback", + ) + .sign_with_keys(&keys) + .expect("sign feedback"); + let auth = IngestAuth::Http { + pubkey: keys.public_key(), + scopes: vec![Scope::MessagesWrite], + auth_method: HttpAuthMethod::Nip98, + }; + let tracer = Arc::new(VecTracer::default()); + let abstract_state = state_for_request(&tenant, auth.pubkey()); + + { + let (guard, counting) = EmitGuard::arm( + tracer.clone(), + abstract_state.clone(), + "ingest_event_exited_without_trace", + ); + emit_product_feedback_success(&counting, &tenant, &event, &auth); + drop(guard); + } + + let steps = tracer.steps.lock().expect("trace lock"); + assert_eq!(steps.len(), 1); + assert!(matches!( + steps[0].action, + TraceAction::WriteInsertGlobal { .. } + )); + } #[test] fn nip_ia_requests_are_global_only() { @@ -2352,6 +2606,76 @@ mod tests { assert!(!requires_h_channel_scope(KIND_USER_STATUS)); } + #[test] + fn private_sidecars_and_moderation_commands_require_messages_write_scope() { + let dummy = make_dummy_event(); + for kind in [ + KIND_REPORT, + KIND_PRODUCT_FEEDBACK, + KIND_MODERATION_BAN, + KIND_MODERATION_UNBAN, + KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNTIMEOUT, + KIND_MODERATION_RESOLVE_REPORT, + ] { + assert_eq!( + required_scope_for_kind(kind, &dummy).unwrap(), + Scope::MessagesWrite, + "kind {kind} should require MessagesWrite scope" + ); + } + } + + #[test] + fn moderation_commands_are_global_only() { + for kind in [ + KIND_MODERATION_BAN, + KIND_MODERATION_UNBAN, + KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNTIMEOUT, + KIND_MODERATION_RESOLVE_REPORT, + ] { + assert!(is_global_only_kind(kind), "kind {kind} must be global-only"); + assert!( + !requires_h_channel_scope(kind), + "kind {kind} must not require an h tag" + ); + } + } + + #[test] + fn moderation_command_rejection_from_ingest_preserves_prefix() { + let rejection = "restricted: moderator access required".to_string(); + let map_rejection = IngestError::Rejected; + let result: Result<(), String> = Err(rejection.clone()); + + match result.map_err(map_rejection).unwrap_err() { + IngestError::Rejected(message) => assert_eq!(message, rejection), + _ => panic!("expected rejected ingest error"), + } + } + + #[test] + fn push_infrastructure_failures_are_internal_not_protocol_invalid() { + match map_push_accept_error(crate::handlers::push_lease::AcceptError::Internal( + "gateway unavailable".to_string(), + )) { + IngestError::Internal(message) => { + assert_eq!(message, "gateway unavailable"); + assert!(!message.starts_with("invalid:")); + } + _ => panic!("infrastructure failure became a protocol rejection"), + } + match map_push_accept_error(crate::handlers::push_lease::AcceptError::Validation( + "unknown executor key".to_string(), + )) { + IngestError::Rejected(message) => { + assert_eq!(message, "invalid: unknown executor key") + } + _ => panic!("validation failure did not become a protocol rejection"), + } + } + #[test] fn global_only_and_channel_scoped_are_disjoint() { // A kind cannot be both global-only and channel-scoped @@ -2375,6 +2699,13 @@ mod tests { KIND_PROFILE, KIND_DELETION, KIND_REACTION, + KIND_REPORT, + KIND_PRODUCT_FEEDBACK, + KIND_MODERATION_BAN, + KIND_MODERATION_UNBAN, + KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNTIMEOUT, + KIND_MODERATION_RESOLVE_REPORT, KIND_STREAM_MESSAGE, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, @@ -2435,15 +2766,6 @@ mod tests { } } - #[test] - fn mesh_llm_relay_status_is_global_only_and_relay_only() { - assert!(is_global_only_kind(KIND_MESH_LLM_RELAY_STATUS)); - assert!(buzz_core::kind::is_relay_only_kind( - KIND_MESH_LLM_RELAY_STATUS - )); - assert!(!requires_h_channel_scope(KIND_MESH_LLM_RELAY_STATUS)); - } - #[test] fn agent_turn_metric_is_global_only_and_in_scope_allowlist() { let dummy = make_dummy_event(); @@ -3019,6 +3341,31 @@ mod tests { assert!(validate_persona_envelope(&ev).is_ok()); } + #[test] + fn persona_envelope_accepts_promptless_content() { + // Unified agent model: system_prompt is optional — a definition can be + // pure configuration. The relay validates only the envelope, so a + // prompt-less body must ingest identically to a full one. + let ev = make_event_with_tags( + KIND_PERSONA, + r#"{"display_name":"config-only"}"#, + &[&["d", "config-only"]], + ); + assert!(validate_persona_envelope(&ev).is_ok()); + } + + #[test] + fn persona_envelope_accepts_behavioral_fields() { + // Unknown legacy fields in persona content remain relay-opaque; + // unknown-field tolerance is the contract. + let ev = make_event_with_tags( + KIND_PERSONA, + r#"{"display_name":"x","respond_to":"owner-only","respond_to_allowlist":[],"mcp_toolsets":"default","parallelism":2}"#, + &[&["d", "behavioral"]], + ); + assert!(validate_persona_envelope(&ev).is_ok()); + } + #[test] fn persona_envelope_accepts_single_char() { let ev = make_persona(&[&["d", "a"]]); diff --git a/crates/buzz-relay/src/handlers/mesh_signaling.rs b/crates/buzz-relay/src/handlers/mesh_signaling.rs deleted file mode 100644 index 4a6f1becb1..0000000000 --- a/crates/buzz-relay/src/handlers/mesh_signaling.rs +++ /dev/null @@ -1,815 +0,0 @@ -//! Mesh hole-punch signaling: the relay's only role in the v1 direct-iroh mesh. -//! -//! v1 mesh is "Buzz-coordinated direct iroh" — no server-side iroh relay/proxy. -//! The relay's entire job for connectivity is: when a member asks to dial a peer -//! it discovered via kind:30621, validate that BOTH ends are relay members, then -//! emit a *paired* live "call-me-now" so both desktops hole-punch at the same -//! moment. The relay never sees or stores the bulk iroh traffic — only this tiny -//! control-plane exchange. -//! -//! Flow: -//! desktop A (member) reads 30621 → already holds B's `EndpointAddr` → -//! publishes KIND_MESH_CONNECT_REQUEST (24621) `#p=B` with both endpoint addrs → -//! relay validates B is a member → mints two relay-signed KIND_MESH_CALL_ME_NOW -//! (24622): one `#p=A` carrying B's addr, one `#p=B` carrying A's addr → -//! both fan out over the existing channel-less ephemeral path (local + Redis) → -//! both desktops dial simultaneously → direct QUIC. -//! -//! The relay is ENDPOINT-STATELESS here: the requester supplies both dial hints -//! (it read them from the relay-signed 30621), so the relay only validates -//! membership and pairs. Endpoint addrs are dial hints, never auth — membership -//! is the gate. - -use std::sync::Arc; - -use buzz_core::event::StoredEvent; -use buzz_core::kind::{ - event_kind_u32, KIND_MESH_CALL_ME_NOW, KIND_MESH_CONNECT_REQUEST, KIND_MESH_STATUS_REPORT, -}; -use buzz_core::tenant::{CommunityId, TenantContext}; -use nostr::{EventBuilder, Kind, Tag}; - -use crate::api::relay_members::{check_relay_membership, MembershipDecision}; -use crate::state::AppState; - -/// Check + bump the per-requester 24621 rate limit (20/sec window). -/// -/// Each accepted connect request makes the relay sign + fan TWO 24622s, so we -/// bound the amplification. 20/sec is far above any real interactive use; a -/// buggy desktop loop can't storm the relay. Shared by the WS door -/// (`handlers::event`) and the HTTP door (`handle_mesh_event_http`) — one -/// limiter, two transports. -pub(crate) fn connect_request_rate_limited(state: &AppState, pubkey: &nostr::PublicKey) -> bool { - let key: [u8; 32] = pubkey.to_bytes(); - let now = std::time::Instant::now(); - let mut entry = state - .mesh_connect_rate_limiter - .entry(key) - .or_insert((0, now)); - let (count, window_start) = entry.value_mut(); - if now.duration_since(*window_start).as_secs() >= 1 { - *count = 1; - *window_start = now; - false - } else { - *count += 1; - *count > 20 - } -} - -/// HTTP-door entry point for the two desktop-published mesh signaling kinds -/// (24620 status report, 24621 connect request). -/// -/// These kinds are ephemeral, so `ingest_event`'s per-kind allowlist -/// (deliberately) rejects them — historically they arrived only over the WS -/// path in `handlers::event`. Since the desktop's Rust coordinator publishes -/// them via `POST /events` (NIP-98), the bridge routes them here instead. -/// Mirrors the WS door's checks in the same order: signature, pubkey-match -/// (strict — mesh kinds must come from the member's own session), rate limit, then the -/// shared handlers. Membership is enforced both at the bridge and inside the -/// handlers (fail-closed `require_mesh_member`). -/// -/// Returns the same message the WS door would put in its OK frame; the bridge -/// maps `Err` to HTTP 400. -pub async fn handle_mesh_event_http( - state: &Arc, - tenant: &TenantContext, - auth_pubkey: &nostr::PublicKey, - event: &nostr::Event, -) -> Result<(), String> { - let event_clone = event.clone(); - tokio::task::spawn_blocking(move || buzz_core::verification::verify_event(&event_clone)) - .await - .map_err(|_| "error: internal verification error".to_string())? - .map_err(|e| format!("invalid: {e}"))?; - - if event.pubkey != *auth_pubkey { - return Err("invalid: event pubkey does not match authenticated identity".to_string()); - } - - let pubkey_hex = auth_pubkey.to_hex(); - match event_kind_u32(event) { - k if k == KIND_MESH_STATUS_REPORT => { - handle_status_report(state, tenant, &pubkey_hex, event).await - } - k if k == KIND_MESH_CONNECT_REQUEST => { - if connect_request_rate_limited(state, auth_pubkey) { - return Err("rate-limited: mesh connect request rate exceeded (20/sec)".to_string()); - } - handle_connect_request(state, tenant, &pubkey_hex, event).await - } - k => Err(format!("invalid: kind {k} is not a mesh signaling kind")), - } -} - -/// Parsed `KIND_MESH_CONNECT_REQUEST` (24621) content. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConnectRequest { - /// Requester's own iroh EndpointAddr (base64 invite token) — sent to the peer. - pub self_endpoint_addr: String, - /// Peer's iroh EndpointAddr (base64 invite token), read by the requester from - /// the peer's kind:30621 serve target — sent back to the requester. - pub peer_endpoint_addr: String, - /// Requester's own iroh endpoint id (optional) — correlation/instrumentation - /// only, never trusted for auth. Copied into the peer's call-me-now. - pub self_endpoint_id: Option, - /// Peer's iroh endpoint id (optional) — correlation/instrumentation only. - /// Copied into the requester's call-me-now so the desktop can target the - /// exact peer endpoint it picked from 30621 (multi-endpoint disambiguation). - pub peer_endpoint_id: Option, - /// Correlates the two halves of one punch attempt. - pub attempt_id: String, -} - -/// Outcome of validating + parsing a connect request, before any relay state is touched. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RequestError { - /// Content was not valid JSON in the expected shape. - Malformed(String), - /// No `#p` tag naming the target peer. - MissingTarget, -} - -/// Parse the JSON content of a 24621 connect request. Pure — no I/O. -pub fn parse_connect_request(content: &str) -> Result { - let v: serde_json::Value = serde_json::from_str(content) - .map_err(|e| RequestError::Malformed(format!("not JSON: {e}")))?; - let get = |k: &str| { - v.get(k) - .and_then(|x| x.as_str()) - .filter(|s| !s.is_empty()) - .map(str::to_string) - }; - let self_endpoint_addr = get("self_endpoint_addr") - .ok_or_else(|| RequestError::Malformed("self_endpoint_addr".into()))?; - let peer_endpoint_addr = get("peer_endpoint_addr") - .ok_or_else(|| RequestError::Malformed("peer_endpoint_addr".into()))?; - let attempt_id = - get("attempt_id").ok_or_else(|| RequestError::Malformed("attempt_id".into()))?; - Ok(ConnectRequest { - self_endpoint_addr, - peer_endpoint_addr, - self_endpoint_id: get("self_endpoint_id"), - peer_endpoint_id: get("peer_endpoint_id"), - attempt_id, - }) -} - -/// Extract the single `#p` target pubkey (hex) from the request event's tags. -/// If multiple `#p` tags are present, the first wins; multi-target mesh-connect -/// is not supported in v1. -pub fn extract_target_pubkey(event: &nostr::Event) -> Option { - event.tags.iter().find_map(|t| { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "p" { - Some(s[1].clone()) - } else { - None - } - }) -} - -/// Build the JSON content for one call-me-now (24622) directed at `recipient`, -/// telling it to dial `peer_endpoint_addr` (optionally `peer_endpoint_id` for -/// multi-endpoint disambiguation). Pure — no I/O. -pub fn call_me_now_content( - peer_endpoint_addr: &str, - peer_endpoint_id: Option<&str>, - attempt_id: &str, - expires_at: u64, -) -> String { - let mut obj = serde_json::json!({ - "v": 1, - "type": "buzz-iroh-call-me-now", - "peer_endpoint_addr": peer_endpoint_addr, - "attempt_id": attempt_id, - "expires_at": expires_at, - }); - if let Some(eid) = peer_endpoint_id { - obj["peer_endpoint_id"] = serde_json::Value::String(eid.to_string()); - } - obj.to_string() -} - -/// Pure: does a membership decision admit a peer into the v1 mesh? Direct relay -/// members (or open relays) only — `ViaOwner` (NIP-OA delegated) is intentionally -/// NOT admitted in v1, keeping the mesh trust boundary tighter and legible. This -/// is applied identically to BOTH the requester and the target, so the two ends -/// are symmetric. Isolated as a pure fn so the trust gate is unit-testable -/// without an `AppState`. -pub fn membership_admits_mesh(decision: &MembershipDecision) -> bool { - matches!( - decision, - MembershipDecision::OpenRelay | MembershipDecision::Member - ) -} - -/// Seconds a call-me-now is valid; iroh's punch loop runs ~60s, so this bounds -/// how stale a signal a desktop should act on. -pub const CALL_ME_NOW_TTL_SECS: u64 = 60; - -/// Handle a verified KIND_MESH_CONNECT_REQUEST (24621) from an authenticated -/// relay member. Validates the target is also a member, then emits the paired -/// call-me-now to both ends. Returns Ok(()) on success or an Err(reason) string -/// suitable for an OK(false) reply (reason is for the requester, not secret). -pub async fn handle_connect_request( - state: &Arc, - tenant: &TenantContext, - requester_pubkey_hex: &str, - event: &nostr::Event, -) -> Result<(), String> { - let req = parse_connect_request(&event.content) - .map_err(|e| format!("invalid: malformed mesh connect request ({e:?})"))?; - - let target_hex = extract_target_pubkey(event) - .ok_or_else(|| "invalid: mesh connect request missing #p target".to_string())?; - - if target_hex == requester_pubkey_hex { - return Err("invalid: cannot mesh-connect to self".to_string()); - } - - // Membership gate, applied SYMMETRICALLY to both ends — direct relay members - // only, gated purely by relay access. The requester reached this handler via - // a NIP-42-authed WS, but that auth can be ViaOwner (NIP-OA delegated) when - // BUZZ_ALLOW_NIP_OA_AUTH is on; v1 mesh excludes delegated identities, so we - // re-check the requester here with no auth tag (which makes ViaOwner - // unreachable — only Member/OpenRelay/Denied) to match the target check. - require_mesh_member(state, tenant.community(), requester_pubkey_hex) - .await - .map_err(|_| "restricted: delegated identities cannot initiate mesh in v1".to_string())?; - - require_mesh_member(state, tenant.community(), &target_hex) - .await - .map_err(|_| "restricted: target is not a relay member".to_string())?; - - let expires_at = (chrono::Utc::now().timestamp().max(0) as u64) + CALL_ME_NOW_TTL_SECS; - - // Pair: tell the requester to dial the peer's addr, and the peer to dial the - // requester's addr. Each is a relay-signed ephemeral #p-addressed event. - // endpoint_id (if supplied) is copied through for desktop multi-endpoint - // disambiguation — it is correlation metadata, never trusted for auth. - let to_requester = build_call_me_now( - state, - requester_pubkey_hex, - &req.peer_endpoint_addr, - req.peer_endpoint_id.as_deref(), - &req.attempt_id, - expires_at, - )?; - let to_target = build_call_me_now( - state, - &target_hex, - &req.self_endpoint_addr, - req.self_endpoint_id.as_deref(), - &req.attempt_id, - expires_at, - )?; - - publish_channelless_ephemeral(state, tenant, &to_requester).await; - publish_channelless_ephemeral(state, tenant, &to_target).await; - Ok(()) -} - -/// Async: confirm `pubkey_hex` is a direct relay member admissible to the mesh. -/// `None` auth_tag → ViaOwner is unreachable, so only Member/OpenRelay admit; -/// everything else (Denied, ViaOwner-if-it-somehow-appeared, or a check error) -/// FAILS CLOSED. Used symmetrically for requester and target. -async fn require_mesh_member( - state: &Arc, - community: CommunityId, - pubkey_hex: &str, -) -> Result<(), ()> { - let bytes = hex::decode(pubkey_hex).map_err(|_| ())?; - match check_relay_membership(state, community, &bytes, None).await { - Ok(d) if membership_admits_mesh(&d) => Ok(()), - Ok(_) => Err(()), - Err(e) => { - tracing::warn!("mesh connect: membership check failed (fail-closed): {e}"); - Err(()) - } - } -} - -/// Mint one relay-signed call-me-now (24622) addressed to `recipient_hex`. -fn build_call_me_now( - state: &Arc, - recipient_hex: &str, - peer_endpoint_addr: &str, - peer_endpoint_id: Option<&str>, - attempt_id: &str, - expires_at: u64, -) -> Result { - let content = call_me_now_content(peer_endpoint_addr, peer_endpoint_id, attempt_id, expires_at); - let p_tag = Tag::parse(["p", recipient_hex]) - .map_err(|e| format!("error: failed to build p tag: {e}"))?; - EventBuilder::new(Kind::Custom(KIND_MESH_CALL_ME_NOW as u16), content) - .tags([p_tag]) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| format!("error: failed to sign call-me-now: {e}")) -} - -/// Publish a channel-less ephemeral event over the same path NIP-AB pairing uses: -/// Redis fan-out (nil-UUID global routing key) for cross-pod, plus direct local -/// WS fan-out. The recipient's desktop receives it via a REQ on `#p=self kind:24622`. -/// -/// Observability note: call-me-now is delivered via `#p` subscription matching, -/// so it is NOT private to the recipient — any member can REQ `kind:24622 -/// #p=` and observe who that member is mesh-connecting to (and the -/// endpoint addrs). This matches presence/typing being broadly observable and is -/// intentional for v1. A conn-scoped private delivery channel would be needed to -/// hide it. -async fn publish_channelless_ephemeral( - state: &Arc, - tenant: &TenantContext, - event: &nostr::Event, -) { - state.mark_local_event(tenant.community(), &event.id); - if let Err(e) = state - .pubsub - .publish_event(tenant, buzz_pubsub::EventTopic::Global, event) - .await - { - state - .local_event_ids - .invalidate(&(tenant.community(), event.id.to_bytes())); - tracing::warn!(event_id = %event.id, "mesh call-me-now global publish failed: {e}"); - } - let stored = StoredEvent::new(event.clone(), None); - // Routed through the guarded send path for uniformity; the access gate - // no-ops for this globally-scoped (channel_id = None) call-me-now event. - crate::handlers::event::fan_out_event_to_local_subscribers(state, tenant.community(), &stored) - .await; -} - -/// Handle a verified KIND_MESH_STATUS_REPORT (24620) from an authenticated relay -/// member. The member reports its current mesh `/api/status` JSON; the relay -/// sanitizes it and republishes a relay-signed kind:30621 discovery note keyed -/// to the reporter (so members' notes never clobber each other). The report -/// itself is ephemeral — only the relay's projection is durable. Membership is -/// already enforced (the reporter is authenticated on a member-gated WS). -pub async fn handle_status_report( - state: &Arc, - tenant: &TenantContext, - reporter_pubkey_hex: &str, - event: &nostr::Event, -) -> Result<(), String> { - // Same membership symmetry as handle_connect_request: a NIP-OA delegated - // (ViaOwner) identity is authed on the WS but is NOT a v1 mesh participant. - // If we let it report, the relay would advertise a serve_target under that - // pubkey that the connect path (which denies ViaOwner) then refuses — broken - // discovery. So gate the reporter the same way: direct members only, fail - // closed. Keeps all three desktop-facing mesh kinds consistent on delegation. - require_mesh_member(state, tenant.community(), reporter_pubkey_hex) - .await - .map_err(|_| { - "restricted: delegated identities cannot report mesh status in v1".to_string() - })?; - - let payload: serde_json::Value = serde_json::from_str(&event.content) - .map_err(|e| format!("invalid: mesh status report content is not JSON ({e})"))?; - crate::mesh_status_publisher::publish_mesh_status_from_payload( - state, - tenant, - reporter_pubkey_hex, - &payload, - ) - .await - .map_err(|e| { - tracing::warn!("mesh status report publish failed: {e}"); - "error: failed to publish mesh status".to_string() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_valid_request() { - let c = r#"{"self_endpoint_addr":"AAA","peer_endpoint_addr":"BBB","attempt_id":"x1"}"#; - let r = parse_connect_request(c).unwrap(); - assert_eq!(r.self_endpoint_addr, "AAA"); - assert_eq!(r.peer_endpoint_addr, "BBB"); - assert_eq!(r.attempt_id, "x1"); - } - - #[test] - fn parse_rejects_missing_field() { - let c = r#"{"self_endpoint_addr":"AAA","attempt_id":"x1"}"#; - assert!(matches!( - parse_connect_request(c), - Err(RequestError::Malformed(_)) - )); - } - - #[test] - fn parse_rejects_empty_field() { - let c = r#"{"self_endpoint_addr":"","peer_endpoint_addr":"BBB","attempt_id":"x1"}"#; - assert!(matches!( - parse_connect_request(c), - Err(RequestError::Malformed(_)) - )); - } - - #[test] - fn parse_rejects_non_json() { - assert!(matches!( - parse_connect_request("not json"), - Err(RequestError::Malformed(_)) - )); - } - - #[test] - fn call_me_now_content_shape() { - let s = call_me_now_content("ENDPOINT", None, "att-1", 1234); - let v: serde_json::Value = serde_json::from_str(&s).unwrap(); - assert_eq!(v["type"], "buzz-iroh-call-me-now"); - assert_eq!(v["peer_endpoint_addr"], "ENDPOINT"); - assert_eq!(v["attempt_id"], "att-1"); - assert_eq!(v["expires_at"], 1234); - assert_eq!(v["v"], 1); - // No endpoint id supplied → field omitted entirely. - assert!(v.get("peer_endpoint_id").is_none()); - } - - #[test] - fn call_me_now_content_includes_endpoint_id_when_present() { - let s = call_me_now_content("ENDPOINT", Some("EID-7"), "att-1", 1234); - let v: serde_json::Value = serde_json::from_str(&s).unwrap(); - assert_eq!(v["peer_endpoint_id"], "EID-7"); - } - - #[test] - fn parse_round_trips_optional_endpoint_ids() { - let c = r#"{"self_endpoint_addr":"A","peer_endpoint_addr":"B","self_endpoint_id":"SI","peer_endpoint_id":"PI","attempt_id":"x"}"#; - let r = parse_connect_request(c).unwrap(); - assert_eq!(r.self_endpoint_id.as_deref(), Some("SI")); - assert_eq!(r.peer_endpoint_id.as_deref(), Some("PI")); - // endpoint ids are optional — absent is fine. - let c2 = r#"{"self_endpoint_addr":"A","peer_endpoint_addr":"B","attempt_id":"x"}"#; - let r2 = parse_connect_request(c2).unwrap(); - assert_eq!(r2.self_endpoint_id, None); - assert_eq!(r2.peer_endpoint_id, None); - } - - // This is the single pure predicate behind the requester, target, AND - // reporter gates. v1 admits only direct relay members (or open relays); - // NIP-OA-delegated (ViaOwner) and Denied are excluded, symmetrically. - - #[test] - fn member_and_open_relay_are_admitted() { - assert!(membership_admits_mesh(&MembershipDecision::Member)); - assert!(membership_admits_mesh(&MembershipDecision::OpenRelay)); - } - - #[test] - fn denied_is_not_admitted() { - assert!(!membership_admits_mesh(&MembershipDecision::Denied)); - } - - #[test] - fn via_owner_is_not_admitted_in_v1() { - // Delegated identities are excluded from v1 mesh on every desktop-facing - // path (requester / target / reporter all run through this predicate). - let owner = nostr::Keys::generate().public_key(); - assert!(!membership_admits_mesh(&MembershipDecision::ViaOwner( - owner - ))); - } - - #[test] - fn extract_target_takes_the_p_tag() { - let keys = nostr::Keys::generate(); - let target = nostr::Keys::generate().public_key().to_hex(); - let event = nostr::EventBuilder::new(nostr::Kind::Custom(24621), "{}") - .tags([nostr::Tag::parse(["p", &target]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - assert_eq!( - extract_target_pubkey(&event).as_deref(), - Some(target.as_str()) - ); - } - - #[test] - fn extract_target_none_without_p_tag() { - let keys = nostr::Keys::generate(); - let event = nostr::EventBuilder::new(nostr::Kind::Custom(24621), "{}") - .sign_with_keys(&keys) - .unwrap(); - assert_eq!(extract_target_pubkey(&event), None); - } - - fn p_tag() -> nostr::SingleLetterTag { - nostr::SingleLetterTag::lowercase(nostr::Alphabet::P) - } - - fn test_config() -> crate::config::Config { - let mut config = crate::config::Config::from_env().expect("default config loads"); - config.require_relay_membership = false; - config.redis_url = "redis://127.0.0.1:1".to_string(); - config - } - - /// Synthetic tenant for the in-memory mesh signaling tests. These exercise - /// the sub-registry fan-out path (lazy PG pool, dead Redis), so they never - /// touch the `communities` FK — a nil-community tenant is sufficient. - fn test_tenant() -> TenantContext { - TenantContext::resolved(buzz_core::CommunityId::from_uuid(uuid::Uuid::nil()), "test") - } - - async fn test_state() -> std::sync::Arc { - let config = test_config(); - let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); - let db = buzz_db::Db::from_pool(pool.clone()); - let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .expect("redis pool"); - let pubsub = std::sync::Arc::new( - buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) - .await - .expect("pubsub manager"), - ); - let audit = buzz_audit::AuditService::new(pool.clone()); - let auth = buzz_auth::AuthService::new(config.auth.clone()); - let search = buzz_search::SearchService::new(pool.clone()); - let workflow_engine = std::sync::Arc::new(buzz_workflow::WorkflowEngine::new( - db.clone(), - buzz_workflow::WorkflowConfig::default(), - )); - let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = crate::state::AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - nostr::Keys::generate(), - media_storage, - ); - std::sync::Arc::new(state) - } - - fn register_call_me_now_sub( - state: &AppState, - recipient_hex: &str, - sub_id: &str, - ) -> ( - uuid::Uuid, - tokio::sync::mpsc::Receiver, - ) { - let conn_id = uuid::Uuid::new_v4(); - let (tx, rx) = tokio::sync::mpsc::channel(10); - state.conn_manager.register( - conn_id, - tx, - tokio_util::sync::CancellationToken::new(), - test_tenant().community(), - std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), - std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), - 3, - ); - state.sub_registry.register( - conn_id, - sub_id.to_string(), - vec![nostr::Filter::new() - .kind(nostr::Kind::Custom(KIND_MESH_CALL_ME_NOW as u16)) - .custom_tags(p_tag(), [recipient_hex])], - None, - ); - (conn_id, rx) - } - - fn connect_request_event(target_hex: &str) -> nostr::Event { - let content = serde_json::json!({ - "self_endpoint_addr": "SELF_ADDR", - "peer_endpoint_addr": "PEER_ADDR", - "self_endpoint_id": "SELF_ID", - "peer_endpoint_id": "PEER_ID", - "attempt_id": "attempt-1" - }) - .to_string(); - nostr::EventBuilder::new(nostr::Kind::Custom(24621), content) - .tags([nostr::Tag::parse(["p", target_hex]).unwrap()]) - .sign_with_keys(&nostr::Keys::generate()) - .unwrap() - } - - fn event_from_ws_message(msg: axum::extract::ws::Message) -> nostr::Event { - let axum::extract::ws::Message::Text(text) = msg else { - panic!("expected text ws message"); - }; - let v: serde_json::Value = serde_json::from_str(&text).expect("EVENT frame JSON"); - assert_eq!(v[0], "EVENT"); - serde_json::from_value(v[2].clone()).expect("nostr event") - } - - #[tokio::test] - async fn accepted_connect_request_emits_two_relay_signed_call_me_now_events() { - let state = test_state().await; - let requester_hex = nostr::Keys::generate().public_key().to_hex(); - let target_hex = nostr::Keys::generate().public_key().to_hex(); - let (_requester_conn, mut requester_rx) = - register_call_me_now_sub(&state, &requester_hex, "mesh_requester"); - let (_target_conn, mut target_rx) = - register_call_me_now_sub(&state, &target_hex, "mesh_target"); - - handle_connect_request( - &state, - &test_tenant(), - &requester_hex, - &connect_request_event(&target_hex), - ) - .await - .expect("open relay admits both peers and emits pair"); - - let requester_event = event_from_ws_message( - requester_rx - .try_recv() - .expect("requester receives call-me-now"), - ); - let target_event = - event_from_ws_message(target_rx.try_recv().expect("target receives call-me-now")); - assert!( - requester_rx.try_recv().is_err(), - "requester gets exactly one event" - ); - assert!( - target_rx.try_recv().is_err(), - "target gets exactly one event" - ); - - for event in [&requester_event, &target_event] { - assert_eq!( - event.kind, - nostr::Kind::Custom(KIND_MESH_CALL_ME_NOW as u16) - ); - assert_eq!(event.pubkey, state.relay_keypair.public_key()); - event.verify().expect("relay-signed event verifies"); - } - - assert_eq!( - extract_target_pubkey(&requester_event).as_deref(), - Some(requester_hex.as_str()) - ); - assert_eq!( - extract_target_pubkey(&target_event).as_deref(), - Some(target_hex.as_str()) - ); - - let requester_content: serde_json::Value = - serde_json::from_str(&requester_event.content).expect("requester content JSON"); - assert_eq!(requester_content["type"], "buzz-iroh-call-me-now"); - assert_eq!(requester_content["peer_endpoint_addr"], "PEER_ADDR"); - assert_eq!(requester_content["peer_endpoint_id"], "PEER_ID"); - assert_eq!(requester_content["attempt_id"], "attempt-1"); - assert!(requester_content["expires_at"].as_u64().is_some()); - - let target_content: serde_json::Value = - serde_json::from_str(&target_event.content).expect("target content JSON"); - assert_eq!(target_content["type"], "buzz-iroh-call-me-now"); - assert_eq!(target_content["peer_endpoint_addr"], "SELF_ADDR"); - assert_eq!(target_content["peer_endpoint_id"], "SELF_ID"); - assert_eq!(target_content["attempt_id"], "attempt-1"); - assert_eq!( - target_content["expires_at"], - requester_content["expires_at"] - ); - } - - #[tokio::test] - async fn self_target_connect_request_emits_no_call_me_now_events() { - let state = test_state().await; - let requester_hex = nostr::Keys::generate().public_key().to_hex(); - let target_hex = nostr::Keys::generate().public_key().to_hex(); - let (_requester_conn, mut requester_rx) = - register_call_me_now_sub(&state, &requester_hex, "mesh_requester"); - let (_target_conn, mut target_rx) = - register_call_me_now_sub(&state, &target_hex, "mesh_target"); - - let err = handle_connect_request( - &state, - &test_tenant(), - &requester_hex, - &connect_request_event(&requester_hex), - ) - .await - .expect_err("self-target is rejected before emitting"); - assert!(err.contains("self"), "unexpected error: {err}"); - assert!( - requester_rx.try_recv().is_err(), - "requester receives no event" - ); - assert!(target_rx.try_recv().is_err(), "target receives no event"); - } - - // Regression coverage for the post-#879 transport: the desktop's Rust - // coordinator publishes 24620/24621 via POST /events, which used to fall - // into ingest_event's allowlist and 400 with "unknown event kind". - - fn signed_connect_request(keys: &nostr::Keys, target_hex: &str) -> nostr::Event { - let content = serde_json::json!({ - "self_endpoint_addr": "SELF_ADDR", - "peer_endpoint_addr": "PEER_ADDR", - "attempt_id": "attempt-http-1" - }) - .to_string(); - nostr::EventBuilder::new(nostr::Kind::Custom(24621), content) - .tags([nostr::Tag::parse(["p", target_hex]).unwrap()]) - .sign_with_keys(keys) - .unwrap() - } - - #[tokio::test] - async fn http_door_accepts_connect_request_and_emits_pair() { - let state = test_state().await; - let requester = nostr::Keys::generate(); - let requester_hex = requester.public_key().to_hex(); - let target_hex = nostr::Keys::generate().public_key().to_hex(); - let (_rc, mut requester_rx) = - register_call_me_now_sub(&state, &requester_hex, "http_requester"); - let (_tc, mut target_rx) = register_call_me_now_sub(&state, &target_hex, "http_target"); - - handle_mesh_event_http( - &state, - &test_tenant(), - &requester.public_key(), - &signed_connect_request(&requester, &target_hex), - ) - .await - .expect("HTTP door accepts a valid connect request"); - - event_from_ws_message(requester_rx.try_recv().expect("requester call-me-now")); - event_from_ws_message(target_rx.try_recv().expect("target call-me-now")); - } - - #[tokio::test] - async fn http_door_rejects_pubkey_mismatch() { - let state = test_state().await; - let signer = nostr::Keys::generate(); - let other = nostr::Keys::generate(); - let target_hex = nostr::Keys::generate().public_key().to_hex(); - - let err = handle_mesh_event_http( - &state, - &test_tenant(), - &other.public_key(), - &signed_connect_request(&signer, &target_hex), - ) - .await - .expect_err("signer != authenticated identity must be rejected"); - assert!(err.contains("does not match"), "unexpected error: {err}"); - } - - #[tokio::test] - async fn http_door_rejects_bad_signature() { - let state = test_state().await; - let keys = nostr::Keys::generate(); - let target_hex = nostr::Keys::generate().public_key().to_hex(); - let mut event = signed_connect_request(&keys, &target_hex); - // Tamper after signing. - event.content = "{}".to_string(); - - let err = handle_mesh_event_http(&state, &test_tenant(), &keys.public_key(), &event) - .await - .expect_err("tampered event must be rejected"); - assert!(err.starts_with("invalid:"), "unexpected error: {err}"); - } - - #[tokio::test] - async fn http_door_rejects_non_mesh_kind() { - let state = test_state().await; - let keys = nostr::Keys::generate(); - let event = nostr::EventBuilder::new(nostr::Kind::Custom(20001), "online") - .sign_with_keys(&keys) - .unwrap(); - - let err = handle_mesh_event_http(&state, &test_tenant(), &keys.public_key(), &event) - .await - .expect_err("only 24620/24621 route through the mesh HTTP door"); - assert!( - err.contains("not a mesh signaling kind"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn connect_request_rate_limiter_trips_on_21st_in_window() { - let state = test_state().await; - let pubkey = nostr::Keys::generate().public_key(); - for i in 0..20 { - assert!( - !connect_request_rate_limited(&state, &pubkey), - "request {} should pass", - i + 1 - ); - } - assert!( - connect_request_rate_limited(&state, &pubkey), - "21st request in the same window must be limited" - ); - } -} diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index da47d80a15..98a5e6c51d 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -4,6 +4,8 @@ pub mod auth; pub mod close; /// Command executor — transactional processing for command kinds. pub mod command_executor; +/// Relay-operator community provisioning HTTP support. +pub mod community_provisioning; /// NIP-45 COUNT handler. pub mod count; /// EVENT handler — WS dispatcher → ingest pipeline → fan-out. @@ -14,10 +16,20 @@ pub mod identity_archive; pub mod imeta; /// Transport-neutral event ingestion pipeline. pub mod ingest; -/// Mesh hole-punch signaling: validate membership + emit paired call-me-now. -pub mod mesh_signaling; +/// Community moderation authorization seam (capability helper). +pub mod moderation_authz; +/// Community moderation command handler (kinds 9040–9044). +pub mod moderation_commands; +/// Relay-signed moderation notice DMs. +pub mod moderation_notices; +/// Product-feedback validation + deployment sidecar persistence. +pub mod product_feedback; +#[allow(dead_code, missing_docs)] +pub mod push_lease; /// NIP-43 relay membership admin command handler (kinds 9030–9032). pub mod relay_admin; +/// NIP-56 report (kind:1984) validation + moderation queue persistence. +pub mod report; /// REQ handler — subscribe, deliver historical events, then EOSE. pub mod req; /// NIP-29 and NIP-25 side-effect handlers. diff --git a/crates/buzz-relay/src/handlers/moderation_authz.rs b/crates/buzz-relay/src/handlers/moderation_authz.rs new file mode 100644 index 0000000000..2f2781dd1d --- /dev/null +++ b/crates/buzz-relay/src/handlers/moderation_authz.rs @@ -0,0 +1,336 @@ +//! Community moderation authorization (Phase 1 contract). +//! +//! One capability seam for every moderation decision, per +//! `PLANS/COMMUNITY_MODERATION_PLAN.md` §0.1: roles are community +//! `owner`/`admin` (from tenant-scoped `relay_members`) plus existing +//! channel-level owner/admin. There is no Moderator tier in v1 — but all +//! authorization routes through [`authorize_moderation_action`] so adding one +//! later is a policy change, not a rewrite. +//! +//! ## Tenant invariant +//! Authority never crosses the tenant fence: the actor's role is read from +//! `relay_members` / `channel_members` under `tenant.community()` only, and +//! callers must have already resolved `target` inside the same tenant. +//! +//! Lane ownership: L2 (Mari). Signatures below are the contract. + +use std::sync::Arc; + +use buzz_core::tenant::TenantContext; +use uuid::Uuid; + +use crate::state::AppState; + +/// A moderation capability being exercised. +/// +/// V1 capability grid (plan §4 Gap A): community owner/admin hold all of +/// these community-wide; channel owner/admin hold `DeleteMessage`/`Kick` +/// within their channel only; members hold none. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModerationAction { + /// Delete any message (kind:9005 path). + DeleteMessage, + /// Remove/kick a user from a channel (kind:9001 path). + Kick, + /// Ban a user from the community (community owner/admin only). + Ban, + /// Lift a community ban. + Unban, + /// Time-box a user's writes (community owner/admin only). + Timeout, + /// Clear a timeout early. + Untimeout, + /// Resolve/dismiss/escalate reports in the moderation queue. + ResolveReport, + /// Read the moderation queue and audit log. + ViewQueue, +} + +/// What the action is aimed at (already tenant-resolved by the caller). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModerationTarget<'a> { + /// An event (32-byte id) in `channel_id`'s community. + Event(&'a [u8]), + /// A member pubkey in this community. + Pubkey(&'a [u8]), + /// No specific target (queue/audit reads). + None, +} + +/// Why an authorization succeeded — recorded in the audit row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModerationAuthority { + /// Actor is community `owner` in `relay_members`. + CommunityOwner, + /// Actor is community `admin` in `relay_members`. + CommunityAdmin, + /// Actor is channel owner/admin of the target's channel. + ChannelRole, +} + +/// Decide whether `actor` may perform `action` on `target`. +/// +/// - Community `owner`/`admin` (tenant-scoped `relay_members.role`) are +/// authorized for every [`ModerationAction`] in any channel of their +/// community — this is the bridge `validate_admin_event` is missing today. +/// - Channel owner/admin keep their existing channel-local authority for +/// `DeleteMessage`/`Kick` (via `channel_id`). +/// - Guard rails (plan): an admin cannot ban/timeout the community owner or +/// a fellow admin; only the owner can action an admin. +/// +/// Returns the matched authority for the audit row, or `Err` with a +/// client-safe denial message. +pub async fn authorize_moderation_action( + tenant: &TenantContext, + state: &Arc, + actor_pubkey: &[u8], + channel_id: Option, + target: ModerationTarget<'_>, + action: ModerationAction, +) -> anyhow::Result { + let community = tenant.community(); + + // Community role: `relay_members` stores pubkeys as 64-char hex, fenced to + // `community` in the query itself. This is the primary authority — owner and + // admin can moderate any channel in their community. + let actor_role = state + .db + .get_relay_member(community, &hex::encode(actor_pubkey)) + .await? + .map(|m| m.role); + + // The target's community role is read only for the admin guard rail — i.e. + // an admin actioning a pubkey with ban/timeout — so the owner and + // channel-role paths stay at a single query. + let target_role = match (actor_role.as_deref(), action, target) { + (Some("admin"), ModerationAction::Ban | ModerationAction::Timeout, target) => { + match target { + ModerationTarget::Pubkey(pk) => state + .db + .get_relay_member(community, &hex::encode(pk)) + .await? + .map(|m| m.role), + _ => None, + } + } + _ => None, + }; + + // The channel role is read only when community authority does not apply and + // the action is channel-local (DeleteMessage/Kick within `channel_id`). + let channel_role = match (actor_role.as_deref(), action, channel_id) { + (Some("owner") | Some("admin"), _, _) => None, + (_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => { + state + .db + .get_member_role(community, channel_id, actor_pubkey) + .await? + } + _ => None, + }; + + decide_authority( + actor_role.as_deref(), + target_role.as_deref(), + channel_role.as_deref(), + action, + ) +} + +/// Pure authorization decision from resolved roles — the policy, factored out +/// of the I/O so it is exhaustively unit-testable. +/// +/// - `actor_role` / `target_role`: community `relay_members` role, if any. +/// - `channel_role`: the actor's channel role, resolved by the caller only when +/// community authority does not apply and the action is channel-local. +fn decide_authority( + actor_role: Option<&str>, + target_role: Option<&str>, + channel_role: Option<&str>, + action: ModerationAction, +) -> anyhow::Result { + match actor_role { + // Owner holds every capability, community-wide, with no guard rail. + Some("owner") => Ok(ModerationAuthority::CommunityOwner), + // Admin holds every capability, but cannot ban/timeout the owner or a + // fellow admin — only the owner may action an admin. The guard trips only + // on a target *role* of owner/admin: a target with no `relay_members` row + // (a drive-by spammer who already left) is bannable. Unban/Untimeout lift + // a restriction and are intentionally unguarded — a banned admin can't + // self-unban (banned means blocked at the auth seam before any command + // runs), so the only reachable case is an admin lifting a fellow admin's + // restriction, which is benign, audited, and owner-reversible; guarding it + // would instead strand a wrongly-banned admin behind an owner-only unlock. + Some("admin") => { + if matches!(action, ModerationAction::Ban | ModerationAction::Timeout) + && matches!(target_role, Some("owner") | Some("admin")) + { + anyhow::bail!("an admin cannot ban or time out a community owner or fellow admin"); + } + Ok(ModerationAuthority::CommunityAdmin) + } + // Not a community owner/admin: channel owner/admin keep channel-local + // authority for DeleteMessage/Kick only. + _ => match (action, channel_role) { + ( + ModerationAction::DeleteMessage | ModerationAction::Kick, + Some("owner") | Some("admin"), + ) => Ok(ModerationAuthority::ChannelRole), + _ => anyhow::bail!("moderator access required"), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every community-wide action a community owner can take. Channel-local + /// actions (DeleteMessage/Kick) are included — the owner holds them too. + const ALL_ACTIONS: [ModerationAction; 8] = [ + ModerationAction::DeleteMessage, + ModerationAction::Kick, + ModerationAction::Ban, + ModerationAction::Unban, + ModerationAction::Timeout, + ModerationAction::Untimeout, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ]; + + fn ok(r: anyhow::Result) -> ModerationAuthority { + r.expect("expected authorization") + } + + #[test] + fn community_owner_authorized_for_everything() { + for action in ALL_ACTIONS { + // Even against another owner/admin target: the owner has no guard rail. + assert_eq!( + ok(decide_authority(Some("owner"), Some("admin"), None, action)), + ModerationAuthority::CommunityOwner, + "owner must be authorized for {action:?}" + ); + } + } + + #[test] + fn community_admin_authorized_against_non_privileged_targets() { + for action in ALL_ACTIONS { + // Target is a plain member (or unknown) — admin holds every capability. + assert_eq!( + ok(decide_authority( + Some("admin"), + Some("member"), + None, + action + )), + ModerationAuthority::CommunityAdmin, + "admin must be authorized for {action:?} against a member" + ); + assert_eq!( + ok(decide_authority(Some("admin"), None, None, action)), + ModerationAuthority::CommunityAdmin, + "admin must be authorized for {action:?} against a non-member" + ); + } + } + + #[test] + fn admin_cannot_ban_or_timeout_owner_or_fellow_admin() { + for target in ["owner", "admin"] { + for action in [ModerationAction::Ban, ModerationAction::Timeout] { + assert!( + decide_authority(Some("admin"), Some(target), None, action).is_err(), + "admin must not {action:?} a community {target}" + ); + } + } + } + + #[test] + fn admin_can_ban_or_timeout_a_non_member_target() { + // A target with no `relay_members` row (e.g. a drive-by spammer who + // already left) must still be bannable — the guard trips on a privileged + // *role*, never on a missing row. + for action in [ModerationAction::Ban, ModerationAction::Timeout] { + assert_eq!( + ok(decide_authority(Some("admin"), None, None, action)), + ModerationAuthority::CommunityAdmin, + "admin must be able to {action:?} a non-member target" + ); + // A plain member target is likewise fair game. + assert_eq!( + ok(decide_authority( + Some("admin"), + Some("member"), + None, + action + )), + ModerationAuthority::CommunityAdmin, + "admin must be able to {action:?} a plain member" + ); + } + } + + #[test] + fn admin_guard_rail_is_scoped_to_ban_and_timeout() { + // Reversals and non-restriction actions against an admin target are allowed — + // the guard rail protects against *applying* a restriction, not lifting one. + for action in [ + ModerationAction::Unban, + ModerationAction::Untimeout, + ModerationAction::DeleteMessage, + ModerationAction::Kick, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ] { + assert_eq!( + ok(decide_authority(Some("admin"), Some("admin"), None, action)), + ModerationAuthority::CommunityAdmin, + "admin must be authorized for {action:?} even against an admin target" + ); + } + } + + #[test] + fn channel_role_covers_only_delete_and_kick() { + for role in ["owner", "admin"] { + for action in [ModerationAction::DeleteMessage, ModerationAction::Kick] { + assert_eq!( + ok(decide_authority(None, None, Some(role), action)), + ModerationAuthority::ChannelRole, + "channel {role} must be authorized for {action:?}" + ); + } + // No community authority: channel role does NOT grant community actions. + for action in [ + ModerationAction::Ban, + ModerationAction::Timeout, + ModerationAction::Unban, + ModerationAction::Untimeout, + ModerationAction::ResolveReport, + ModerationAction::ViewQueue, + ] { + assert!( + decide_authority(None, None, Some(role), action).is_err(), + "channel {role} must NOT be authorized for community action {action:?}" + ); + } + } + } + + #[test] + fn plain_channel_member_and_stranger_are_denied() { + for action in ALL_ACTIONS { + assert!( + decide_authority(None, None, Some("member"), action).is_err(), + "channel member must be denied {action:?}" + ); + assert!( + decide_authority(None, None, None, action).is_err(), + "user with no role must be denied {action:?}" + ); + } + } +} diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs new file mode 100644 index 0000000000..4a74352b4c --- /dev/null +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -0,0 +1,727 @@ +//! Community moderation command handler (kinds 9040–9044, Phase 1 contract). +//! +//! Mirrors the NIP-43 relay-admin pattern (`relay_admin.rs`, 9030-series): +//! commands are validated + executed directly and are **never** stored as +//! regular events. Authorization goes through +//! [`crate::handlers::moderation_authz::authorize_moderation_action`] — +//! never inline role checks. +//! +//! | Kind | Operation | Side effects (all mandatory) | +//! |------|----------------|----------------------------------------------------| +//! | 9040 | Ban | `community_bans` upsert, audit row, live disconnect (L4 fanout), restriction notice DM (L5) | +//! | 9041 | Unban | ban lift, audit row | +//! | 9042 | Timeout | `muted_until` upsert, audit row, notice DM | +//! | 9043 | Untimeout | mute clear, audit row | +//! | 9044 | Resolve report | report status update, audit row, reporter notice DM; `delete`/`kick`/`ban` resolutions fan out through the existing 9005/9001 + 9040 paths | +//! +//! Targets (`p` tag pubkey, `report` tag row id) are resolved under the +//! request's `TenantContext` only. +//! +//! ## Routing (pinned — Wren contract review, 2026-07-07) +//! 9040–9044 are **community-global direct commands**, exactly like the +//! relay-admin 9030-series: route via +//! [`buzz_core::kind::is_moderation_command_kind`], list them in +//! `is_global_only_kind` so a stray `h` tag can never channel-scope them +//! (no channel membership/archive gates apply), require a fresh timestamp, +//! never store them, and reject channel-scoped API tokens. +//! +//! ## Tag vocabulary (pinned — CLI and relay must agree) +//! - 9040 ban: `["p", ]` required; optional +//! `["expiration", ]` (absent ⇒ permanent), `["reason", ]`. +//! - 9041 unban: `["p", ]`. +//! - 9042 timeout: `["p", ]` + required `["expiration", ]`; +//! optional `["reason", ]`. +//! - 9043 untimeout: `["p", ]`. +//! - 9044 resolve (pinned — thread event `86f46207`, 2026-07-07): required, +//! exactly one each: `["report", ]` (the 1984 report +//! being resolved; resolves under `tenant.community()` only), +//! `["status", resolved|dismissed]`, +//! `["action", delete|kick|ban|timeout|dismiss|escalate]` (`dismiss` pairs +//! with status `dismissed`; everything else with `resolved`). Optional +//! `["reason", ]` — audited into `moderation_actions.public_reason` +//! and relayed in the notice DM (so it must be safe for the reporter's +//! eyes; `private_reason` is mod-only and not fed by 9044 tags). Unknown +//! extra tags are ignored, not rejected +//! (forward-compat). `delete`/`kick`/`ban`/`timeout` actions fan out through +//! the existing 9005/9001 paths and the 9040/9042 handlers — no second +//! implementation. The resolution audit row records the *decision*, not the +//! enforcement, so it is prefixed `resolve:` (`resolve:ban`, `resolve:delete`, +//! …); the client's paired 9040-9043 writes the unprefixed enforcement row. +//! The `resolve:*` values are part of the DB CHECK vocabulary in migration 0006. +//! `dismiss` audits as `dismiss_report` and `escalate` as `escalate` (both +//! unprefixed — escalate must stay queryable for the platform-safety lane). +//! +//! Lane ownership: L6 (Quinn) — plus `buzz-cli` `moderation` command group. +//! The `ingest.rs` routing entries (scope map + `is_global_only_kind` + +//! direct-processing dispatch) for 9040–9044 belong to L3 (Perci): +//! coordinate, don't edit ingest.rs. + +use std::sync::Arc; + +use buzz_core::kind::{ + KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, +}; +use buzz_core::tenant::TenantContext; +use chrono::{DateTime, TimeZone, Utc}; +use nostr::Event; +use tracing::info; +use uuid::Uuid; + +use crate::handlers::moderation_authz::{ + authorize_moderation_action, ModerationAction, ModerationTarget, +}; +use crate::handlers::moderation_notices::{send_moderation_notice, ModerationNotice}; +use crate::state::AppState; +use buzz_db::moderation::NewAction; + +/// Max clock skew for a freshly-signed command (mirrors `relay_admin.rs` and +/// the NIP-42 auth freshness window). Commands are never stored, so replay of a +/// captured command is the only threat and a tight window is the mitigation. +const MAX_COMMAND_SKEW_SECS: i64 = 120; + +/// Validate and execute a moderation command (kinds 9040–9044). +/// +/// Returns a client-safe error string for `OK false` on rejection. +/// +/// Routing note: 9040–9044 are community-global direct commands (L3 lists them +/// in `is_global_only_kind`), so no `h`/channel context is consulted here; the +/// tenant is bound from the request. Authorization goes through +/// [`authorize_moderation_action`] — never inline role checks. +pub async fn handle_moderation_command( + tenant: &TenantContext, + state: &Arc, + event: &Event, +) -> Result<(), String> { + let kind = event.kind.as_u16() as u32; + let actor = event.pubkey.to_bytes().to_vec(); + + // Freshness: reject stale/replayed commands (they are never stored). + let event_ts = event.created_at.as_secs() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > MAX_COMMAND_SKEW_SECS { + return Err(invalid(format!( + "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)", + event_ts - now + ))); + } + + match kind { + KIND_MODERATION_BAN => handle_ban(tenant, state, event, &actor).await, + KIND_MODERATION_UNBAN => handle_unban(tenant, state, event, &actor).await, + KIND_MODERATION_TIMEOUT => handle_timeout(tenant, state, event, &actor).await, + KIND_MODERATION_UNTIMEOUT => handle_untimeout(tenant, state, event, &actor).await, + KIND_MODERATION_RESOLVE_REPORT => handle_resolve(tenant, state, event, &actor).await, + other => Err(invalid(format!( + "unexpected moderation command kind: {other}" + ))), + } +} + +// ── 9040: ban ─────────────────────────────────────────────────────────────── + +async fn handle_ban( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let target = extract_p_tag_bytes(event).ok_or_else(|| invalid("missing or invalid p tag"))?; + let expires_at = extract_expiration(event)?; // None ⇒ permanent + let reason = extract_tag_value(event, "reason"); + + authorize_moderation_action( + tenant, + state, + actor, + None, + ModerationTarget::Pubkey(&target), + ModerationAction::Ban, + ) + .await + .map_err(authz_denial)?; + + state + .db + .ban_community_member( + tenant.community(), + &target, + actor, + reason.as_deref(), + expires_at, + ) + .await + .map_err(|e| error(format!("database error: {e}")))?; + + let action_id = insert_audit( + state, + tenant, + actor, + "ban", + Some(&target), + None, + reason.as_deref(), + ) + .await?; + + // Live enforcement: close open sessions for the banned principal now — + // this pod's sockets synchronously (fenced to this community) and every + // other pod's via the fire-and-forget cross-pod fan-out. The paired helper + // makes "close locally but forget the Redis publish" unrepresentable, so a + // live ban takes effect immediately, everywhere (decision 4). + state.disconnect_pubkey_clusterwide( + tenant, + &target, + &event.id.to_hex(), + "blocked: you are banned from this community", + ); + + // Notice DM: tell the banned user the terms of the restriction. + let public_reason = reason.clone().unwrap_or_default(); + if let Err(e) = send_moderation_notice( + tenant, + state, + &target, + ModerationNotice::Restriction { + action_id, + kind: "ban".to_string(), + public_reason, + }, + ) + .await + { + // Notice delivery is best-effort; the ban itself has already landed and + // been audited. Log and continue rather than fail the command. + info!(error = %e, "ban notice DM delivery failed (ban still enforced)"); + } + + info!(target = %hex::encode(&target), "community ban applied"); + Ok(()) +} + +// ── 9041: unban ────────────────────────────────────────────────────────────── + +async fn handle_unban( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let target = extract_p_tag_bytes(event).ok_or_else(|| invalid("missing or invalid p tag"))?; + + authorize_moderation_action( + tenant, + state, + actor, + None, + ModerationTarget::Pubkey(&target), + ModerationAction::Unban, + ) + .await + .map_err(authz_denial)?; + + let lifted = state + .db + .unban_community_member(tenant.community(), &target, actor) + .await + .map_err(|e| error(format!("database error: {e}")))?; + if !lifted { + return Err(invalid("member is not banned")); + } + + insert_audit(state, tenant, actor, "unban", Some(&target), None, None).await?; + + info!(target = %hex::encode(&target), "community ban lifted"); + Ok(()) +} + +// ── 9042: timeout ──────────────────────────────────────────────────────────── + +async fn handle_timeout( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let target = extract_p_tag_bytes(event).ok_or_else(|| invalid("missing or invalid p tag"))?; + let muted_until = + extract_expiration(event)?.ok_or_else(|| invalid("timeout requires an expiration tag"))?; + let reason = extract_tag_value(event, "reason"); + + authorize_moderation_action( + tenant, + state, + actor, + None, + ModerationTarget::Pubkey(&target), + ModerationAction::Timeout, + ) + .await + .map_err(authz_denial)?; + + state + .db + .timeout_community_member( + tenant.community(), + &target, + actor, + muted_until, + reason.as_deref(), + ) + .await + .map_err(|e| error(format!("database error: {e}")))?; + + let action_id = insert_audit( + state, + tenant, + actor, + "timeout", + Some(&target), + None, + reason.as_deref(), + ) + .await?; + + let public_reason = reason.clone().unwrap_or_default(); + if let Err(e) = send_moderation_notice( + tenant, + state, + &target, + ModerationNotice::Restriction { + action_id, + kind: "timeout".to_string(), + public_reason, + }, + ) + .await + { + info!(error = %e, "timeout notice DM delivery failed (timeout still enforced)"); + } + + info!(target = %hex::encode(&target), "community timeout applied"); + Ok(()) +} + +// ── 9043: untimeout ────────────────────────────────────────────────────────── + +async fn handle_untimeout( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let target = extract_p_tag_bytes(event).ok_or_else(|| invalid("missing or invalid p tag"))?; + + authorize_moderation_action( + tenant, + state, + actor, + None, + ModerationTarget::Pubkey(&target), + ModerationAction::Untimeout, + ) + .await + .map_err(authz_denial)?; + + let cleared = state + .db + .untimeout_community_member(tenant.community(), &target, actor) + .await + .map_err(|e| error(format!("database error: {e}")))?; + if !cleared { + return Err(invalid("member is not timed out")); + } + + insert_audit(state, tenant, actor, "untimeout", Some(&target), None, None).await?; + + info!(target = %hex::encode(&target), "community timeout cleared"); + Ok(()) +} + +// ── 9044: resolve report ───────────────────────────────────────────────────── + +async fn handle_resolve( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let report_event_id = extract_report_tag(event) + .ok_or_else(|| invalid("missing or invalid report tag (expect 64-hex event id)"))?; + let status = extract_tag_value(event, "status").ok_or_else(|| invalid("missing status tag"))?; + let action = extract_tag_value(event, "action").ok_or_else(|| invalid("missing action tag"))?; + let reason = extract_tag_value(event, "reason"); + + // Vocab is validated at build time in the SDK, but the relay must not trust + // the client: re-validate the pinned vocabulary here. + if status != "resolved" && status != "dismissed" { + return Err(invalid(format!( + "invalid status: {status} (expect resolved|dismissed)" + ))); + } + if !matches!( + action.as_str(), + "delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" + ) { + return Err(invalid(format!( + "invalid action: {action} (expect delete|kick|ban|timeout|dismiss|escalate)" + ))); + } + if (action == "dismiss") != (status == "dismissed") { + return Err(invalid( + "action `dismiss` pairs only with status `dismissed`", + )); + } + + authorize_moderation_action( + tenant, + state, + actor, + None, + ModerationTarget::Event(&report_event_id), + ModerationAction::ResolveReport, + ) + .await + .map_err(authz_denial)?; + + // Resolve the report row under this tenant only. The `report` tag carries + // the signed 1984 event id (pinned contract); look the row up by it. + let report = state + .db + .get_moderation_report_by_event(tenant.community(), &report_event_id) + .await + .map_err(|e| error(format!("database error: {e}")))? + .ok_or_else(|| invalid("report not found in this community"))?; + + // Don't write an audit row for a report someone else already closed. The + // DB's `WHERE status='open'` on resolve_moderation_report below is the real + // guard; this early check keeps a lost-race resolve (two mods on the same + // report) from leaving an orphan audit row behind the failed resolve. A tiny + // residual race remains — the row can flip to closed between this read and + // the DB write — but that window yields only an audit row plus a failed + // resolve, which is tolerated. + if report.status != "open" { + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); + } + + // Carry the report's own target into the audit row so `delete`/`kick`/`ban` + // resolutions record what they acted on. + let (target_pubkey, target_event_id) = match &report.target { + buzz_db::moderation::ReportTarget::Pubkey(p) => (Some(p.as_slice()), None), + buzz_db::moderation::ReportTarget::Event(e) => (None, Some(e.as_slice())), + buzz_db::moderation::ReportTarget::Blob(_) => (None, None), + }; + + // Distinguish a resolution *decision* from the actual *enforcement* row. + // A one-click resolve with action=ban records the moderator's decision; the + // client then composes the real 9040, which writes its own "ban" enforcement + // row. `resolve:*` decision rows are part of the moderation_actions DB + // vocabulary so audit consumers can tell the two apart and don't double-count. + // `dismiss_report` and `escalate` stay unprefixed — escalate especially must + // remain queryable for the platform-safety lane. + let audit_action = resolution_audit_action(&action); + let action_id = insert_audit( + state, + tenant, + actor, + audit_action, + target_pubkey, + target_event_id, + reason.as_deref(), + ) + .await?; + + let resolved = state + .db + .resolve_moderation_report( + tenant.community(), + report.id, + &status, + actor, + Some(action_id), + ) + .await + .map_err(|e| error(format!("database error: {e}")))?; + if !resolved { + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); + } + + // Close the loop: DM the reporter that their report was reviewed. + let summary = reason.clone().unwrap_or_else(|| match status.as_str() { + "dismissed" => "Your report was reviewed and dismissed.".to_string(), + _ => "Your report was reviewed and acted on.".to_string(), + }); + if let Err(e) = send_moderation_notice( + tenant, + state, + &report.reporter_pubkey, + ModerationNotice::ReportResolved { + report_id: report.id, + status: status.clone(), + summary, + }, + ) + .await + { + info!(error = %e, "report-resolution notice DM delivery failed (report still resolved)"); + } + + info!(report_id = %report.id, status = %status, action = %action, "report resolved"); + Ok(()) +} + +// ── shared helpers ──────────────────────────────────────────────────────────── + +fn resolution_audit_action(action: &str) -> &'static str { + match action { + "dismiss" => "dismiss_report", + "escalate" => "escalate", + "delete" => "resolve:delete", + "kick" => "resolve:kick", + "ban" => "resolve:ban", + "timeout" => "resolve:timeout", + // The caller validates this vocabulary before mapping. + _ => "resolve:unknown", + } +} + +/// Insert a moderation audit row for an accepted command. `matched_principal` +/// is left `None` here: that NIP-OA field records which principal an +/// *enforcement* check matched at the auth seam (L4), not who issued a command. +async fn insert_audit( + state: &Arc, + tenant: &TenantContext, + actor: &[u8], + action: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + public_reason: Option<&str>, +) -> Result { + state + .db + .insert_moderation_action( + tenant.community(), + NewAction { + actor_pubkey: actor, + action, + target_pubkey, + target_event_id, + channel_id: None, + reason_code: None, + public_reason, + private_reason: None, + matched_principal: None, + }, + ) + .await + .map_err(|e| error(format!("failed to write audit row: {e}"))) +} + +/// Map an authorization error to a client-safe `restricted:`-prefixed denial. +fn authz_denial(e: anyhow::Error) -> String { + format!("restricted: {e}") +} + +fn invalid(message: impl Into) -> String { + format!("invalid: {}", message.into()) +} + +fn error(message: impl Into) -> String { + format!("error: {}", message.into()) +} + +/// Extract the first valid `p` tag as raw pubkey bytes (32 bytes). +fn extract_p_tag_bytes(event: &Event) -> Option> { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some("p") { + if let Some(val) = parts.get(1).map(|s| s.as_str()) { + if val.len() == 64 && val.chars().all(|c| c.is_ascii_hexdigit()) { + return hex::decode(val).ok(); + } + } + } + } + None +} + +/// Extract the `report` tag as a 32-byte event id (the signed 1984 report). +fn extract_report_tag(event: &Event) -> Option> { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some("report") { + if let Some(val) = parts.get(1).map(|s| s.as_str()) { + if val.len() == 64 && val.chars().all(|c| c.is_ascii_hexdigit()) { + return hex::decode(val).ok(); + } + } + } + } + None +} + +/// Parse an optional `expiration` tag (unix seconds) into a UTC timestamp. +/// Returns `Ok(None)` when absent, `Err` on a malformed value. +fn extract_expiration(event: &Event) -> Result>, String> { + match extract_tag_value(event, "expiration") { + None => Ok(None), + Some(raw) => { + let secs: i64 = raw + .parse() + .map_err(|_| invalid(format!("invalid expiration tag: {raw}")))?; + match Utc.timestamp_opt(secs, 0).single() { + Some(ts) => Ok(Some(ts)), + None => Err(invalid(format!("expiration out of range: {secs}"))), + } + } + } +} + +/// Extract the value of the first tag with the given name. +fn extract_tag_value(event: &Event, name: &str) -> Option { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some(name) { + return parts.get(1).map(|s| s.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// Build a signed event with the given kind, timestamp, and tags. + fn make_event(kind: u16, created_at_secs: u64, tags: Vec>) -> Event { + let keys = Keys::generate(); + let nostr_tags: Vec = tags + .into_iter() + .map(|parts| Tag::parse(parts).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::from(kind), "") + .tags(nostr_tags) + .custom_created_at(nostr::Timestamp::from_secs(created_at_secs)) + .sign_with_keys(&keys) + .expect("signing failed") + } + + fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + } + + #[test] + fn resolve_audit_actions_are_allowed_by_db_check_vocabulary() { + for action in ["dismiss", "escalate", "delete", "kick", "ban", "timeout"] { + let audit_action = resolution_audit_action(action); + assert!( + buzz_db::moderation::MODERATION_ACTION_CHECK_VOCAB.contains(&audit_action), + "9044 action={action} maps to {audit_action}, which must be accepted by migrations/0006_moderation.sql moderation_actions.action CHECK" + ); + } + } + + #[test] + fn command_error_prefix_helpers_preserve_machine_readable_token() { + assert_eq!( + authz_denial(anyhow::anyhow!("moderator access required")), + "restricted: moderator access required" + ); + assert_eq!(invalid("missing status tag"), "invalid: missing status tag"); + assert_eq!( + error("database error: connection lost"), + "error: database error: connection lost" + ); + } + + #[test] + fn extract_p_tag_bytes_valid() { + let hex = "a".repeat(64); + let e = make_event(9040, now_secs(), vec![vec!["p".into(), hex.clone()]]); + assert_eq!(extract_p_tag_bytes(&e), hex::decode(&hex).ok()); + } + + #[test] + fn extract_p_tag_bytes_rejects_short_and_nonhex() { + assert_eq!( + extract_p_tag_bytes(&make_event( + 9040, + now_secs(), + vec![vec!["p".into(), "abcd".into()]] + )), + None + ); + let bad = "g".repeat(64); + assert_eq!( + extract_p_tag_bytes(&make_event(9040, now_secs(), vec![vec!["p".into(), bad]])), + None + ); + } + + #[test] + fn extract_report_tag_requires_64_hex() { + let id = "b".repeat(64); + let e = make_event(9044, now_secs(), vec![vec!["report".into(), id.clone()]]); + assert_eq!(extract_report_tag(&e), hex::decode(&id).ok()); + // A UUID-shaped value (Wren's L5 lesson: never a UUID where an event id belongs). + let uuid = make_event( + 9044, + now_secs(), + vec![vec![ + "report".into(), + "550e8400-e29b-41d4-a716-446655440000".into(), + ]], + ); + assert_eq!(extract_report_tag(&uuid), None); + } + + #[test] + fn expiration_absent_is_none() { + let e = make_event(9040, now_secs(), vec![]); + assert_eq!(extract_expiration(&e).unwrap(), None); + } + + #[test] + fn expiration_valid_parses() { + let e = make_event( + 9040, + now_secs(), + vec![vec!["expiration".into(), "1893456000".into()]], + ); + assert_eq!( + extract_expiration(&e).unwrap(), + Utc.timestamp_opt(1_893_456_000, 0).single() + ); + } + + #[test] + fn expiration_malformed_errs() { + let e = make_event( + 9040, + now_secs(), + vec![vec!["expiration".into(), "not-a-number".into()]], + ); + assert!(extract_expiration(&e).is_err()); + } + + #[test] + fn expiration_out_of_range_errs() { + let e = make_event( + 9040, + now_secs(), + vec![vec!["expiration".into(), "99999999999999".into()]], + ); + assert!(extract_expiration(&e).is_err()); + } +} diff --git a/crates/buzz-relay/src/handlers/moderation_notices.rs b/crates/buzz-relay/src/handlers/moderation_notices.rs new file mode 100644 index 0000000000..8f57eea71f --- /dev/null +++ b/crates/buzz-relay/src/handlers/moderation_notices.rs @@ -0,0 +1,398 @@ +//! Relay-signed moderation notice DMs (Phase 1 contract). +//! +//! Plan §0.3 (Tyler, 2026-07-07): every resolution/action notice is a real +//! nostr message in the DB, authored by the relay moderation key: +//! +//! 1. Create/reuse the two-party DM channel `{relay mod key, user}` via the +//! participant-hash-idempotent DM model (`buzz-db/src/dm.rs`). +//! 2. Emit kind:39000 discovery with `hidden`, `t=dm`, and `p` tags. +//! 3. Insert a relay-signed kind:9 with `h=`. +//! 4. Publish a relay kind:0 profile named "{Community} Moderation". +//! +//! One DM thread per user per community. Non-replyable in v1 (replies are +//! v2 appeal routing). The same primitive carries reporter-resolution, +//! actioned-author, and timeout/ban notices. +//! +//! ## Privacy +//! Notices to an actioned author never name the reporter(s) or quote report +//! notes. Notices to a reporter never reveal other reporters. +//! +//! Lane ownership: L5 (Sami). + +use std::sync::Arc; + +use nostr::{EventBuilder, Kind, Tag}; +use tracing::warn; +use uuid::Uuid; + +use buzz_core::kind::{event_kind_u32, KIND_STREAM_MESSAGE}; +use buzz_core::tenant::TenantContext; + +use super::event::dispatch_persistent_event; +use super::side_effects::emit_group_discovery_events; +use crate::state::AppState; + +/// Tag naming the moderation source row (report/action) a notice was derived +/// from. Deliberately non-standard: `e` is reserved for 32-byte event ids, but +/// the source is an opaque DB row UUID. Used for idempotency and client linking. +const MODERATION_SOURCE_TAG: &str = "moderation_source"; + +/// Which notice is being delivered — determines template + audience. +#[derive(Debug, Clone)] +pub enum ModerationNotice { + /// To a reporter: their report was reviewed; outcome summary. + ReportResolved { + /// The resolved report row. + report_id: Uuid, + /// `resolved` | `dismissed`. + status: String, + /// Sanitized outcome line (no reporter/mod identities beyond policy). + summary: String, + }, + /// To an actioned author: which message, which rule, what happened. + ContentActioned { + /// The audit action row. + action_id: Uuid, + /// Sanitized reason (mirrors the tombstone's `public_reason`). + public_reason: String, + }, + /// To a banned/timed-out user: terms of the restriction. + Restriction { + /// The audit action row. + action_id: Uuid, + /// `ban` | `timeout` (with expiry rendered into the message). + kind: String, + /// Sanitized reason. + public_reason: String, + }, +} + +/// Deliver a moderation notice to `recipient` in this community's +/// relay-authored DM thread (created on first use, reused after). +/// +/// Crash-retry safe per (action/report id, recipient): a retry after a +/// committed insert is a no-op; concurrent duplicate sends are not serialized +/// in v1. +pub async fn send_moderation_notice( + tenant: &TenantContext, + state: &Arc, + recipient_pubkey: &[u8], + notice: ModerationNotice, +) -> anyhow::Result<()> { + if recipient_pubkey.len() != 32 { + anyhow::bail!( + "moderation notice recipient must be a 32-byte pubkey, got {}", + recipient_pubkey.len() + ); + } + let relay_pubkey = state.relay_keypair.public_key(); + let relay_pubkey_bytes = relay_pubkey.to_bytes(); + let relay_pubkey_hex = hex::encode(relay_pubkey_bytes); + + // Never DM the relay key itself (would create a self-DM and is meaningless). + if recipient_pubkey == relay_pubkey_bytes.as_slice() { + return Ok(()); + } + + // 1. Create/reuse the two-party DM channel {relay mod key, recipient}. + // `open_dm` is participant-hash idempotent, so re-delivery to the same + // user reuses the one thread per (community, user). + let (dm_channel, was_created) = state + .db + .open_dm( + tenant.community(), + &[recipient_pubkey], + relay_pubkey_bytes.as_slice(), + ) + .await?; + let dm_channel_id = dm_channel.id; + + // Count new DM creation; side-effect gates below intentionally do not + // gate on was_created (see comment at step 2). + if was_created { + metrics::counter!( + "buzz_channels_created_total", + "community" => tenant.host().to_owned(), + "type" => "dm" + ) + .increment(1); + } + + // Resurface the moderation DM for the recipient. `open_dm` only clears + // `hidden_at` for `created_by` (the relay key), so a user who hid the + // "{host} Moderation" thread would never see a later ban/resolution notice. + // The closed-loop trust requirement needs the notice to reappear. + state + .db + .unhide_dm(tenant.community(), dm_channel_id, recipient_pubkey) + .await?; + + // Idempotency: a notice for this source id already exists in this DM ⇒ no-op. + // The source (report/action) row id is carried in a `moderation_source` tag + // (NOT `e` — `e` is reserved for 32-byte event ids; this is an opaque row + // UUID). Keyed on it, a retry after a crash between insert and fan-out is a + // safe no-op. Note: this is query-then-insert, so it is crash-retry safe but + // not concurrency-safe — two simultaneous deliveries for the same source can + // both miss the pre-query. Callers invoke this once per action from + // already-serialized side-effect paths; hard per-source serialization is a + // noted follow-up, not done here. + let source_id = notice.source_id(); + if notice_already_sent(state, tenant, dm_channel_id, &relay_pubkey_bytes, source_id).await? { + return Ok(()); + } + + // 2. Ensure the relay's "{host} Moderation" kind:0 profile exists, and 3. + // the DM's kind:39000 discovery (with `hidden` / `t=dm` / `p`). Both are + // replaceable events, so we emit them on EVERY send rather than gating on + // first creation: if discovery failed on the first delivery (it is + // `?`-propagated), a `was_created`-gated retry would skip it forever and + // leave the thread permanently undiscoverable — a notice delivered into a + // channel no client can render. Notices are rare; unconditional re-emit is + // cheap and `replace_addressable_event` makes it idempotent. + if let Err(e) = publish_moderation_profile(tenant, state, &relay_pubkey_hex).await { + warn!(error = %e, "moderation profile publish failed (continuing)"); + } + emit_group_discovery_events(tenant, state, dm_channel_id).await?; + + // 4. Insert the relay-signed kind:9 notice with `h=` and a + // `moderation_source` tag naming the source row id (idempotency + + // client linking). + let tags = vec![ + Tag::parse(["h", &dm_channel_id.to_string()])?, + Tag::parse([MODERATION_SOURCE_TAG, &source_id.to_string()])?, + ]; + let event = EventBuilder::new( + Kind::Custom(KIND_STREAM_MESSAGE as u16), + notice.body(tenant), + ) + .tags(tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign moderation notice: {e}"))?; + + let (stored, _inserted) = state + .db + .insert_event(tenant.community(), &event, Some(dm_channel_id)) + .await?; + + let kind_u32 = event_kind_u32(&stored.event); + dispatch_persistent_event(tenant, state, &stored, kind_u32, &relay_pubkey_hex, None).await; + + Ok(()) +} + +/// Publish the relay-signed kind:0 "{host} Moderation" profile so clients can +/// render the DM author with a recognizable name. Replaceable (NIP-01), so +/// re-emitting is idempotent — the latest wins. +async fn publish_moderation_profile( + tenant: &TenantContext, + state: &Arc, + relay_pubkey_hex: &str, +) -> anyhow::Result<()> { + let name = format!("{} Moderation", tenant.host()); + let metadata = serde_json::json!({ + "name": name, + "display_name": name, + "about": "Automated notices about moderation actions in this community. \ + Replies are not monitored.", + }); + let event = EventBuilder::new(Kind::Metadata, metadata.to_string()) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign moderation profile: {e}"))?; + + // kind:0 is a replaceable event; store globally (channel_id = None) like + // every other user profile so it is resolvable by any client. + let (stored, was_inserted) = state + .db + .replace_addressable_event(tenant.community(), &event, None) + .await?; + if was_inserted { + let kind_u32 = event_kind_u32(&stored.event); + dispatch_persistent_event(tenant, state, &stored, kind_u32, relay_pubkey_hex, None).await; + } + Ok(()) +} + +/// True if a relay-authored notice for `source_id` already exists in this DM. +/// +/// Idempotency scan scoped to the recipient's single moderation DM thread +/// (kind:9, relay-authored) — bounded by that user's own notice history, so no +/// unbounded read. Matches the opaque `moderation_source` tag in Rust because +/// `EventQuery` only pushes down standardized `e`/`d`/`p` tags and this row id +/// is intentionally not an `e` tag (see `MODERATION_SOURCE_TAG`). +/// +/// `limit` is set to the query clamp (1000): matching is post-query in Rust so +/// `Some(1)` would be wrong, and the default 100-row window could let an old +/// source id fall out of view and re-send a duplicate on crash-retry. 1000 +/// moderation notices to one user in one community is a practical ceiling. +async fn notice_already_sent( + state: &Arc, + tenant: &TenantContext, + dm_channel_id: Uuid, + relay_pubkey_bytes: &[u8], + source_id: Uuid, +) -> anyhow::Result { + let existing = state + .db + .query_events(&buzz_db::event::EventQuery { + kinds: Some(vec![KIND_STREAM_MESSAGE as i32]), + channel_id: Some(dm_channel_id), + authors: Some(vec![relay_pubkey_bytes.to_vec()]), + limit: Some(1000), + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await?; + + let source_str = source_id.to_string(); + Ok(existing.iter().any(|stored| { + stored.event.tags.iter().any(|t| { + let parts = t.as_slice(); + parts.len() >= 2 && parts[0] == MODERATION_SOURCE_TAG && parts[1] == source_str + }) + })) +} + +impl ModerationNotice { + /// The source row id this notice is derived from — the idempotency key and + /// the `moderation_source` tag value that lets a client link the notice back + /// to its action. + fn source_id(&self) -> Uuid { + match self { + ModerationNotice::ReportResolved { report_id, .. } => *report_id, + ModerationNotice::ContentActioned { action_id, .. } => *action_id, + ModerationNotice::Restriction { action_id, .. } => *action_id, + } + } + + /// Render the recipient-facing message body. + /// + /// Privacy invariant (module docs): these strings are built only from the + /// notice's own sanitized fields — a report/action status, a summary, and a + /// `public_reason` that already mirrors the tombstone. They never carry + /// reporter identities, other reporters, or raw report notes. + fn body(&self, tenant: &TenantContext) -> String { + let community = tenant.host(); + match self { + ModerationNotice::ReportResolved { + status, summary, .. + } => { + let outcome = match status.as_str() { + "resolved" => "was reviewed and acted on", + "dismissed" => "was reviewed; no action was taken", + "escalated" => "was escalated for further review", + other => other, + }; + format!( + "Thanks for your report to {community}. Your report {outcome}.\n\n{summary}" + ) + } + ModerationNotice::ContentActioned { public_reason, .. } => { + format!( + "A moderator in {community} took action on your content.\n\nReason: {public_reason}" + ) + } + ModerationNotice::Restriction { + kind, + public_reason, + .. + } => { + let action = match kind.as_str() { + "ban" => "You have been banned from", + "timeout" => "You have been timed out in", + other => other, + }; + format!("{action} {community}.\n\nReason: {public_reason}") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tenant() -> TenantContext { + TenantContext::resolved( + buzz_core::CommunityId::from_uuid(Uuid::new_v4()), + "example.org", + ) + } + + #[test] + fn source_id_selects_the_right_field() { + let report = Uuid::new_v4(); + let action = Uuid::new_v4(); + assert_eq!( + ModerationNotice::ReportResolved { + report_id: report, + status: "resolved".into(), + summary: String::new(), + } + .source_id(), + report + ); + assert_eq!( + ModerationNotice::ContentActioned { + action_id: action, + public_reason: String::new(), + } + .source_id(), + action + ); + assert_eq!( + ModerationNotice::Restriction { + action_id: action, + kind: "ban".into(), + public_reason: String::new(), + } + .source_id(), + action + ); + } + + #[test] + fn report_resolved_body_reflects_status_and_never_leaks_reporter() { + let t = tenant(); + let body = ModerationNotice::ReportResolved { + report_id: Uuid::new_v4(), + status: "dismissed".into(), + summary: "The message did not violate community rules.".into(), + } + .body(&t); + assert!(body.contains("example.org")); + assert!(body.contains("no action was taken")); + assert!(body.contains("did not violate")); + } + + #[test] + fn restriction_body_distinguishes_ban_from_timeout() { + let t = tenant(); + let ban = ModerationNotice::Restriction { + action_id: Uuid::new_v4(), + kind: "ban".into(), + public_reason: "Repeated spam.".into(), + } + .body(&t); + assert!(ban.contains("banned from example.org")); + assert!(ban.contains("Repeated spam.")); + + let timeout = ModerationNotice::Restriction { + action_id: Uuid::new_v4(), + kind: "timeout".into(), + public_reason: "Cool off.".into(), + } + .body(&t); + assert!(timeout.contains("timed out in example.org")); + } + + #[test] + fn content_actioned_body_carries_only_the_public_reason() { + let t = tenant(); + let body = ModerationNotice::ContentActioned { + action_id: Uuid::new_v4(), + public_reason: "Off-topic.".into(), + } + .body(&t); + assert!(body.contains("took action on your content")); + assert!(body.contains("Off-topic.")); + } +} diff --git a/crates/buzz-relay/src/handlers/product_feedback.rs b/crates/buzz-relay/src/handlers/product_feedback.rs new file mode 100644 index 0000000000..4585a096e5 --- /dev/null +++ b/crates/buzz-relay/src/handlers/product_feedback.rs @@ -0,0 +1,149 @@ +//! Product-feedback event validation and sidecar persistence. + +use std::sync::Arc; + +use buzz_core::tenant::TenantContext; +use buzz_db::product_feedback::NewProductFeedback; +use nostr::Event; + +use crate::state::AppState; + +const CATEGORIES: &[&str] = &["bug", "praise", "needs-work"]; +const MAX_BODY_BYTES: usize = 32 * 1024; +const MAX_TAGS_BYTES: usize = 64 * 1024; + +/// Validate and persist a product-feedback event outside ordinary event storage. +pub async fn handle( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> Result<(), String> { + let category = parse_category(event)?; + validate_body(&event.content)?; + + let tags = serialize_tags(event)?; + let event_created_at = + chrono::DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) + .ok_or_else(|| "invalid: feedback timestamp is out of range".to_string())?; + + state + .db + .insert_product_feedback( + tenant.community(), + NewProductFeedback { + event_id: event.id.as_bytes(), + submitter_pubkey: &event.pubkey.to_bytes(), + category, + body: &event.content, + tags: &tags, + event_created_at, + }, + ) + .await + .map_err(|e| format!("error: database error inserting product feedback: {e}"))?; + + Ok(()) +} + +fn serialize_tags(event: &Event) -> Result { + let tags_bytes = serde_json::to_vec(&event.tags) + .map_err(|e| format!("error: failed to serialize feedback tags: {e}"))?; + if tags_bytes.len() > MAX_TAGS_BYTES { + return Err(format!( + "invalid: feedback tags exceed maximum size of {MAX_TAGS_BYTES} bytes" + )); + } + serde_json::from_slice(&tags_bytes) + .map_err(|e| format!("error: failed to deserialize feedback tags: {e}")) +} + +fn parse_category(event: &Event) -> Result, String> { + let values: Vec<&str> = event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == "category") + .filter_map(|tag| tag.content()) + .collect(); + match values.as_slice() { + [] => Ok(None), + [category] if CATEGORIES.contains(category) => Ok(Some(category)), + [_] => Err("invalid: unsupported feedback category".to_string()), + _ => Err("invalid: feedback must include at most one category tag".to_string()), + } +} + +fn validate_body(body: &str) -> Result<(), String> { + if body.trim().is_empty() { + return Err("invalid: feedback body must not be empty".to_string()); + } + if body.len() > MAX_BODY_BYTES { + return Err(format!( + "invalid: feedback body exceeds maximum size of {MAX_BODY_BYTES} bytes" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use nostr::{Event, EventBuilder, Keys, Kind, Tag}; + + use super::{parse_category, serialize_tags, validate_body}; + use buzz_core::kind::KIND_PRODUCT_FEEDBACK; + + fn feedback(tags: Vec) -> Event { + EventBuilder::new( + Kind::Custom(KIND_PRODUCT_FEEDBACK as u16), + "Useful feedback", + ) + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign feedback") + } + + #[test] + fn accepts_supported_or_absent_category() { + assert_eq!(parse_category(&feedback(vec![])).unwrap(), None); + let event = feedback(vec![Tag::parse(["category", "bug"]).unwrap()]); + assert_eq!(parse_category(&event).unwrap(), Some("bug")); + } + + #[test] + fn rejects_unknown_or_duplicate_category() { + let unknown = feedback(vec![Tag::parse(["category", "idea"]).unwrap()]); + assert!(parse_category(&unknown) + .unwrap_err() + .contains("unsupported")); + let duplicate = feedback(vec![ + Tag::parse(["category", "bug"]).unwrap(), + Tag::parse(["category", "praise"]).unwrap(), + ]); + assert!(parse_category(&duplicate) + .unwrap_err() + .contains("at most one")); + } + + #[test] + fn body_must_be_nonempty_and_bounded() { + assert!(validate_body(" \n ").is_err()); + assert!(validate_body("works").is_ok()); + assert!(validate_body(&"x".repeat(32 * 1024 + 1)).is_err()); + } + + #[test] + fn tags_are_serialized_and_bounded() { + let small = feedback(vec![Tag::parse([ + "imeta", + "url https://example.test/a.png", + ]) + .unwrap()]); + assert!(serialize_tags(&small).is_ok()); + + let oversized = feedback(vec![ + Tag::parse(["diagnostics", &"x".repeat(64 * 1024)]).unwrap() + ]); + assert!(serialize_tags(&oversized) + .unwrap_err() + .contains("tags exceed maximum size")); + } +} diff --git a/crates/buzz-relay/src/handlers/push_lease.rs b/crates/buzz-relay/src/handlers/push_lease.rs new file mode 100644 index 0000000000..ec56a096fd --- /dev/null +++ b/crates/buzz-relay/src/handlers/push_lease.rs @@ -0,0 +1,771 @@ +//! Strict NIP-PL kind:30350 envelope and plaintext validation. +//! +//! This module performs only syntax/policy validation. Tenant selection remains +//! the caller's responsibility and must come from `TenantContext`, never from +//! the decrypted `origin` member. + +use std::collections::HashSet; + +use nostr::Event; +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Number, Value}; +use sha2::Digest as _; + +pub(crate) const PUSH_KINDS: &[u64] = &[7, 9, 1059, 40007, 46010]; +pub(crate) const URGENT_KINDS: &[u64] = &[]; + +/// NIP-PL addressable push-lease event kind. +pub const KIND_PUSH_LEASE: u32 = 30_350; +/// Largest integer represented exactly by interoperable JSON number implementations. +pub const MAX_SAFE_JSON_INTEGER: u64 = (1_u64 << 53) - 1; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LeaseEnvelope { + pub installation_id: String, + pub expiration: i64, + pub executor_key_id: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct LeasePlaintext { + pub v: u64, + pub origin: String, + pub generation: u64, + pub active: bool, + pub app_profile: Option, + pub transport: Option, + pub endpoint: Option, + pub subscriptions: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Subscription { + pub filter: Map, + pub class: String, + #[serde(default)] + pub ignore: Vec>, + pub suppress: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Suppress { + pub p_tags_max: u64, +} + +#[derive(Debug, Clone, Copy)] +pub struct AppProfile<'a> { + pub id: &'a str, + pub transport: &'a str, +} + +pub struct LeaseLimits<'a> { + pub expected_origin: &'a str, + pub author_hex: &'a str, + pub app_profiles: &'a [AppProfile<'a>], + pub supported_classes: &'a [&'a str], + pub push_kinds: &'a [u64], + pub urgent_kinds: &'a [u64], + pub max_subscriptions: usize, + pub max_kinds: usize, + pub max_authors: usize, + pub max_h: usize, + pub max_tag_values: usize, + pub max_ignore: usize, + pub max_endpoint_len: usize, + pub max_string_len: usize, +} + +/// Validate the signed event's public tags and lease lifetime. +pub fn validate_envelope( + event: &Event, + now: i64, + allowed_skew_secs: i64, + max_lease_ttl_secs: i64, + max_content_len: usize, +) -> Result { + if event.kind.as_u16() as u32 != KIND_PUSH_LEASE { + return Err("wrong event kind".into()); + } + if event.content.len() > max_content_len { + return Err("content too long".into()); + } + + let mut d = None; + let mut expiration = None; + let mut exec = None; + let mut alt_seen = false; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + let Some(name) = parts.first().map(String::as_str) else { + return Err("empty public tag".into()); + }; + if parts.len() != 2 { + return Err(format!("{name} tag must have exactly one value")); + } + let value = &parts[1]; + match name { + "d" if d.replace(value.clone()).is_none() => {} + "expiration" if expiration.replace(value.clone()).is_none() => {} + "exec" if exec.replace(value.clone()).is_none() => {} + "alt" if !alt_seen => alt_seen = true, + "d" | "expiration" | "exec" | "alt" => { + return Err(format!("duplicate {name} tag")); + } + _ => return Err(format!("unexpected public tag: {name}")), + } + } + + let installation_id = d.ok_or_else(|| "missing d tag".to_string())?; + if installation_id.is_empty() || installation_id.len() > 64 { + return Err("invalid d tag length".into()); + } + let expiration = expiration + .ok_or_else(|| "missing expiration tag".to_string())? + .parse::() + .map_err(|_| "expiration must be integer Unix seconds".to_string())?; + if expiration <= now - allowed_skew_secs { + return Err("lease already expired".into()); + } + if expiration > now + max_lease_ttl_secs { + return Err("lease ttl too long".into()); + } + let executor_key_id = exec.ok_or_else(|| "missing exec tag".to_string())?; + if executor_key_id.is_empty() { + return Err("empty exec tag".into()); + } + + Ok(LeaseEnvelope { + installation_id, + expiration, + executor_key_id, + }) +} + +/// Parse one bounded plaintext object, rejecting duplicate keys at every depth and non-exact schemas. +pub fn parse_plaintext(input: &str, max_plaintext_len: usize) -> Result { + if input.len() > max_plaintext_len { + return Err("plaintext too long".into()); + } + let mut deserializer = serde_json::Deserializer::from_str(input); + let value = NoDuplicates + .deserialize(&mut deserializer) + .map_err(|e| e.to_string())?; + deserializer.end().map_err(|e| e.to_string())?; + let object = value + .as_object() + .ok_or_else(|| "lease plaintext must be an object".to_string())?; + let active = object + .get("active") + .ok_or_else(|| "missing active".to_string())? + .as_bool() + .ok_or_else(|| "active must be a boolean".to_string())?; + let required: &[&str] = if active { + &[ + "v", + "origin", + "app_profile", + "transport", + "endpoint", + "generation", + "active", + "subscriptions", + ] + } else { + &["v", "origin", "generation", "active"] + }; + let optional: &[&str] = &[]; + if let Some(key) = required.iter().find(|key| !object.contains_key(**key)) { + return Err(format!("missing {key}")); + } + if let Some(key) = object + .keys() + .find(|key| !required.contains(&key.as_str()) && !optional.contains(&key.as_str())) + { + return Err(format!("unknown field: {key}")); + } + serde_json::from_value(value).map_err(|e| format!("invalid lease schema: {e}")) +} + +/// Validate a parsed v1 plaintext against the server-resolved descriptor policy. +pub fn validate_plaintext(body: &LeasePlaintext, limits: &LeaseLimits<'_>) -> Result<(), String> { + if body.v != 1 { + return Err("unsupported version".into()); + } + if body.generation == 0 || body.generation > MAX_SAFE_JSON_INTEGER { + return Err("generation must be a positive safe integer".into()); + } + if body.origin != limits.expected_origin { + return Err("origin mismatch".into()); + } + check_string(&body.origin, limits.max_string_len)?; + + if !body.active { + if body.app_profile.is_some() + || body.transport.is_some() + || body.endpoint.is_some() + || body.subscriptions.is_some() + { + return Err("inactive lease must use minimal schema".into()); + } + return Ok(()); + } + + let app_profile = body.app_profile.as_deref().ok_or("missing app_profile")?; + let transport = body.transport.as_deref().ok_or("missing transport")?; + let endpoint = body.endpoint.as_deref().ok_or("missing endpoint")?; + let subscriptions = body.subscriptions.as_ref().ok_or("missing subscriptions")?; + let advertised = limits + .app_profiles + .iter() + .find(|profile| profile.id == app_profile) + .ok_or("app profile not supported")?; + if transport != advertised.transport { + return Err("transport mismatch".into()); + } + if endpoint.is_empty() || endpoint.len() > limits.max_endpoint_len { + return Err("invalid endpoint length".into()); + } + check_string(app_profile, limits.max_string_len)?; + check_string(transport, limits.max_string_len)?; + check_string(endpoint, limits.max_endpoint_len)?; + if subscriptions.is_empty() || subscriptions.len() > limits.max_subscriptions { + return Err("subscription quota exceeded".into()); + } + for subscription in subscriptions { + validate_subscription(subscription, limits)?; + } + Ok(()) +} + +fn validate_subscription(sub: &Subscription, limits: &LeaseLimits<'_>) -> Result<(), String> { + if !limits.supported_classes.contains(&sub.class.as_str()) { + return Err("class not supported".into()); + } + validate_filter(&sub.filter, limits, true, &sub.class)?; + if sub.ignore.len() > limits.max_ignore { + return Err("ignore quota exceeded".into()); + } + for filter in &sub.ignore { + // Ignore filters can only subtract from an already-positive match, so + // urgent-kind confinement belongs solely to the positive filter. + validate_filter(filter, limits, false, "")?; + } + if sub.suppress.as_ref().is_some_and(|s| s.p_tags_max == 0) { + return Err("p_tags_max must be positive".into()); + } + Ok(()) +} + +fn validate_filter( + filter: &Map, + limits: &LeaseLimits<'_>, + require_narrowing: bool, + class: &str, +) -> Result<(), String> { + const ALLOWED: &[&str] = &["kinds", "authors", "#p", "#h", "#e"]; + if let Some(key) = filter.keys().find(|key| !ALLOWED.contains(&key.as_str())) { + return Err(format!("filter member not permitted: {key}")); + } + let kinds = string_or_integer_array(filter, "kinds", limits.max_kinds, true)?; + let kinds: Vec = kinds + .iter() + .map(|value| { + value + .as_u64() + .ok_or_else(|| "kind must be an integer".to_string()) + }) + .collect::>()?; + if kinds.iter().any(|kind| !limits.push_kinds.contains(kind)) { + return Err("kind not push-eligible".into()); + } + if class == "urgent" && kinds.iter().any(|kind| !limits.urgent_kinds.contains(kind)) { + return Err("class not permitted for kind".into()); + } + + let authors = optional_string_array(filter, "authors", limits.max_authors)?; + let p = optional_string_array(filter, "#p", limits.max_tag_values)?; + let h = optional_string_array(filter, "#h", limits.max_h)?; + let e = optional_string_array(filter, "#e", limits.max_tag_values)?; + if require_narrowing && authors.is_none() && p.is_none() && h.is_none() { + return Err("lease filter not narrowed".into()); + } + for value in authors.into_iter().flatten() { + check_exact_hex(value, "author")?; + } + for value in p.into_iter().flatten() { + check_exact_hex(value, "p tag")?; + if value != limits.author_hex { + return Err("p-tag must be self".into()); + } + } + for value in h.into_iter().flatten() { + check_string(value, limits.max_string_len)?; + let uuid = uuid::Uuid::parse_str(value).map_err(|_| "invalid h tag".to_string())?; + if uuid.get_version_num() != 4 || uuid.to_string() != *value { + return Err("invalid h tag".into()); + } + } + for value in e.into_iter().flatten() { + check_exact_hex(value, "e tag")?; + } + Ok(()) +} + +fn string_or_integer_array<'a>( + filter: &'a Map, + key: &str, + max: usize, + required: bool, +) -> Result<&'a Vec, String> { + let Some(value) = filter.get(key) else { + return if required { + Err(format!("missing {key}")) + } else { + Err("internal optional-array misuse".into()) + }; + }; + let values = value + .as_array() + .ok_or_else(|| format!("{key} must be an array"))?; + if values.is_empty() || values.len() > max { + return Err(format!("invalid {key} count")); + } + Ok(values) +} + +fn optional_string_array<'a>( + filter: &'a Map, + key: &str, + max: usize, +) -> Result>, String> { + let Some(value) = filter.get(key) else { + return Ok(None); + }; + let values = value + .as_array() + .ok_or_else(|| format!("{key} must be an array"))?; + if values.is_empty() || values.len() > max { + return Err(format!("invalid {key} count")); + } + values + .iter() + .map(|value| { + value + .as_str() + .ok_or_else(|| format!("{key} values must be strings")) + }) + .collect::, _>>() + .map(Some) +} + +fn check_exact_hex(value: &str, label: &str) -> Result<(), String> { + if value.len() != 64 + || !value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(format!("non-exact match value for {label}")); + } + Ok(()) +} + +fn check_string(value: &str, max: usize) -> Result<(), String> { + if value.is_empty() || value.len() > max { + return Err("invalid string length".into()); + } + Ok(()) +} + +struct NoDuplicates; + +impl<'de> DeserializeSeed<'de> for NoDuplicates { + type Value = Value; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(NoDuplicatesVisitor) + } +} + +struct NoDuplicatesVisitor; + +impl<'de> Visitor<'de> for NoDuplicatesVisitor { + type Value = Value; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON value without duplicate object keys") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(value.into())) + } + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(value.into())) + } + fn visit_f64(self, value: f64) -> Result { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite number")) + } + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.into())) + } + fn visit_string(self, value: String) -> Result { + Ok(Value::String(value)) + } + fn visit_none(self) -> Result { + Ok(Value::Null) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_some>(self, d: D) -> Result { + NoDuplicates.deserialize(d) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut values = Vec::new(); + while let Some(value) = seq.next_element_seed(NoDuplicates)? { + values.push(value); + } + Ok(Value::Array(values)) + } + fn visit_map>(self, mut map: A) -> Result { + let mut values = Map::new(); + let mut seen = HashSet::new(); + while let Some(key) = map.next_key::()? { + if !seen.insert(key.clone()) { + return Err(de::Error::custom(format!("duplicate object key: {key}"))); + } + values.insert(key, map.next_value_seed(NoDuplicates)?); + } + Ok(Value::Object(values)) + } +} + +/// A lease rejection is either caller-correctable protocol validation or a +/// transient/internal dependency failure that must remain retryable. +#[derive(Debug)] +pub enum AcceptError { + Validation(String), + Internal(String), +} + +impl From for AcceptError { + fn from(reason: String) -> Self { + Self::Validation(reason) + } +} + +/// Fully validate, provision, and atomically persist one kind:30350 lease. +pub async fn accept( + tenant: &buzz_core::TenantContext, + state: &std::sync::Arc, + event: &Event, + now: i64, +) -> Result { + const MAX_LEASE_TTL: i64 = 30 * 24 * 60 * 60; + const ALLOWED_SKEW: i64 = 120; + const MAX_CONTENT: usize = 65_536; + const MAX_PLAINTEXT: usize = 32_768; + const MAX_ACTIVE_LEASES: i64 = 16; + if state.config.push_gateway_delivery_url.is_none() { + return Err(AcceptError::Validation("push not supported".to_string())); + } + let envelope = validate_envelope(event, now, ALLOWED_SKEW, MAX_LEASE_TTL, MAX_CONTENT)?; + if envelope.executor_key_id != state.config.push_executor_key_id { + return Err(AcceptError::Validation("unknown executor key".to_string())); + } + let plaintext = nostr::nips::nip44::decrypt( + state.relay_keypair.secret_key(), + &event.pubkey, + &event.content, + ) + .map_err(|_| "invalid encrypted content".to_string())?; + let body = parse_plaintext(&plaintext, MAX_PLAINTEXT)?; + let origin = canonical_origin(&state.config.relay_url, tenant.host())?; + let author_hex = event.pubkey.to_hex(); + let limits = LeaseLimits { + expected_origin: &origin, + author_hex: &author_hex, + app_profiles: &[ + AppProfile { + id: "buzz-ios-production", + transport: "apns", + }, + AppProfile { + id: "buzz-ios-sandbox", + transport: "apns", + }, + ], + supported_classes: &["silent", "default", "time_sensitive"], + push_kinds: PUSH_KINDS, + urgent_kinds: URGENT_KINDS, + max_subscriptions: 16, + max_kinds: 16, + max_authors: 20, + max_h: 50, + max_tag_values: 20, + max_ignore: 8, + max_endpoint_len: 4096, + max_string_len: 512, + }; + validate_plaintext(&body, &limits)?; + let generation = + i64::try_from(body.generation).map_err(|_| "invalid generation".to_string())?; + let version = buzz_db::push::LeaseVersion { + source_event_id: event.id.as_bytes(), + source_created_at: event.created_at.as_secs() as i64, + generation, + expires_at: envelope.expiration, + }; + let endpoint_hash; + let subscriptions; + let capability; + let active = if body.active { + let endpoint = body.endpoint.as_deref().expect("validated active endpoint"); + endpoint_hash = sha2::Sha256::digest(endpoint.as_bytes()).to_vec(); + let max_class = body + .subscriptions + .as_ref() + .expect("validated subscriptions") + .iter() + .map(|sub| sub.class.as_str()) + .max_by_key(|class| class_rank(class)) + .expect("non-empty subscriptions"); + capability = endpoint.to_owned(); + subscriptions = serde_json::to_value( + body.subscriptions + .as_ref() + .expect("validated subscriptions"), + ) + .map_err(|_| "invalid subscriptions".to_string())?; + Some(buzz_db::push::ActiveLease { + app_profile: body.app_profile.as_deref().expect("validated profile"), + endpoint_hash: &endpoint_hash, + endpoint_grant: &capability, + max_class, + subscriptions: &subscriptions, + }) + } else { + None + }; + state + .db + .accept_push_lease_event( + tenant.community(), + event, + &envelope.installation_id, + version, + active, + MAX_ACTIVE_LEASES, + ) + .await + .map_err(|_| AcceptError::Internal("lease persistence failed".to_string())) +} + +fn class_rank(class: &str) -> u8 { + match class { + "silent" => 0, + "default" => 1, + "time_sensitive" => 2, + "urgent" => 3, + _ => 0, + } +} + +fn canonical_origin(relay_url: &str, host: &str) -> Result { + let scheme = if relay_url.starts_with("wss://") { + "wss" + } else if relay_url.starts_with("ws://") { + "ws" + } else { + return Err("invalid relay URL".to_string()); + }; + if host.is_empty() { + return Err("invalid tenant host".to_string()); + } + Ok(format!("{scheme}://{host}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + fn event(tags: Vec) -> Event { + EventBuilder::new(Kind::Custom(KIND_PUSH_LEASE as u16), "ciphertext") + .tags(tags) + .custom_created_at(Timestamp::from(1_000_u64)) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + #[test] + fn envelope_rejects_extra_and_duplicate_tags() { + let base = vec![ + Tag::parse(["d", "installation"]).unwrap(), + Tag::parse(["expiration", "1100"]).unwrap(), + Tag::parse(["exec", "key"]).unwrap(), + ]; + assert!(validate_envelope(&event(base.clone()), 1_000, 10, 200, 100).is_ok()); + let mut duplicate = base.clone(); + duplicate.push(Tag::parse(["d", "other"]).unwrap()); + assert_eq!( + validate_envelope(&event(duplicate), 1_000, 10, 200, 100).unwrap_err(), + "duplicate d tag" + ); + let mut extra = base; + extra.push(Tag::parse(["p", "secret"]).unwrap()); + assert_eq!( + validate_envelope(&event(extra), 1_000, 10, 200, 100).unwrap_err(), + "unexpected public tag: p" + ); + } + + #[test] + fn envelope_rejects_wrong_kind() { + let event = EventBuilder::new(Kind::TextNote, "ciphertext") + .tags([ + Tag::parse(["d", "installation"]).unwrap(), + Tag::parse(["expiration", "1100"]).unwrap(), + Tag::parse(["exec", "key"]).unwrap(), + ]) + .custom_created_at(Timestamp::from(1_000_u64)) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert_eq!( + validate_envelope(&event, 1_000, 10, 200, 100).unwrap_err(), + "wrong event kind" + ); + } + + #[test] + fn parser_rejects_duplicate_keys_at_any_depth() { + let top = r##"{"v":1,"v":1,"origin":"o","generation":1,"active":false}"##; + assert!(parse_plaintext(top, 1024) + .unwrap_err() + .contains("duplicate object key: v")); + let nested = r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"e","subscriptions":[{"filter":{"kinds":[9],"kinds":[7],"#p":["aa"]},"class":"default"}]}"##; + assert!(parse_plaintext(nested, 4096) + .unwrap_err() + .contains("duplicate object key: kinds")); + } + + #[test] + fn inactive_schema_is_minimal() { + assert_eq!( + parse_plaintext( + r##"{"v":1,"origin":"o","generation":1,"active":false,"endpoint":"x"}"##, + 1024, + ) + .unwrap_err(), + "unknown field: endpoint" + ); + } + + fn limits<'a>() -> LeaseLimits<'a> { + LeaseLimits { + expected_origin: "o", + author_hex: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + app_profiles: &[AppProfile { + id: "p", + transport: "apns", + }], + supported_classes: &["default", "urgent"], + push_kinds: &[9, 46010], + urgent_kinds: &[46010], + max_subscriptions: 4, + max_kinds: 4, + max_authors: 4, + max_h: 4, + max_tag_values: 4, + max_ignore: 2, + max_endpoint_len: 128, + max_string_len: 128, + } + } + + #[test] + fn migration_trigger_allowlist_matches_advertised_push_kinds() { + let kinds = PUSH_KINDS + .iter() + .map(u64::to_string) + .collect::>() + .join(", "); + let predicate = format!("NEW.kind IN ({kinds})"); + let migration = include_str!("../../../../migrations/0018_push_match_queue.sql"); + assert!( + migration.contains(&predicate), + "migration trigger must use PUSH_KINDS exactly: {predicate}" + ); + } + + #[test] + fn active_filter_requires_narrowing_and_self_p_tag() { + let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9]},"class":"default"}]}"##, 4096).unwrap(); + assert_eq!( + validate_plaintext(&body, &limits()).unwrap_err(), + "lease filter not narrowed" + ); + } + + #[test] + fn profile_transport_and_positive_generation_are_enforced() { + let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"fcm","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#p":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},"class":"default"}]}"##, 4096).unwrap(); + assert_eq!( + validate_plaintext(&body, &limits()).unwrap_err(), + "transport mismatch" + ); + + let body = parse_plaintext( + r##"{"v":1,"origin":"o","generation":0,"active":false}"##, + 4096, + ) + .unwrap(); + assert_eq!( + validate_plaintext(&body, &limits()).unwrap_err(), + "generation must be a positive safe integer" + ); + } + + #[test] + fn h_uses_its_advertised_limit_not_the_generic_tag_limit() { + let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#h":["123e4567-e89b-42d3-a456-426614174000","123e4567-e89b-42d3-a456-426614174001"]},"class":"default"}]}"##, 4096).unwrap(); + let mut limits = limits(); + limits.max_h = 2; + limits.max_tag_values = 1; + assert!(validate_plaintext(&body, &limits).is_ok()); + } + + #[test] + fn canonical_origin_preserves_server_resolved_authority() { + assert_eq!( + canonical_origin("wss://relay.example", "tenant.example:8443").unwrap(), + "wss://tenant.example:8443" + ); + assert_eq!( + canonical_origin("ws://relay.example", "[::1]:3000").unwrap(), + "ws://[::1]:3000" + ); + assert!(canonical_origin("https://relay.example", "tenant.example").is_err()); + assert!(canonical_origin("wss://relay.example", "").is_err()); + } + + #[test] + fn urgent_is_limited_by_event_kind() { + let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#p":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},"class":"urgent"}]}"##, 4096).unwrap(); + assert_eq!( + validate_plaintext(&body, &limits()).unwrap_err(), + "class not permitted for kind" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/report.rs b/crates/buzz-relay/src/handlers/report.rs new file mode 100644 index 0000000000..fccf8eb42b --- /dev/null +++ b/crates/buzz-relay/src/handlers/report.rs @@ -0,0 +1,337 @@ +//! NIP-56 report (kind:1984) validation + persistence (Phase 1 contract). +//! +//! Reports are signals, never triggers (NIP-56): the relay persists them to +//! the tenant-scoped moderation queue and **never** auto-actions or fans them +//! out publicly. +//! +//! ## The pinned invariant (MOD, `docs/spec/MultiTenantRelay.tla`) +//! Report targets resolve under `tenant.community()` **only**: +//! - `e` target → event row looked up in this tenant; infer `channel_id` +//! from it. Not found in-tenant ⇒ reject (never search other tenants). +//! - `x` blob target → tenant-scoped media reference `(community_id, sha256)`. +//! A bare SHA-256 is shared across tenants and must not grant cross-tenant +//! visibility. +//! - `p`-only target → community-local report about that pubkey in this +//! tenant; implies nothing platform/global. +//! +//! Lane ownership: L3 (Perci) — including the `required_scope_for_kind` / +//! storage-suppression wiring in `ingest.rs`. + +use std::sync::Arc; + +use buzz_core::tenant::TenantContext; +use buzz_db::moderation::{NewReport, ReportTarget}; +use nostr::Event; + +use crate::state::AppState; + +/// NIP-56 report types accepted at ingest. +pub const REPORT_TYPES: &[&str] = &[ + "illegal", + "nudity", + "malware", + "spam", + "impersonation", + "profanity", + "other", +]; + +/// Validate a kind:1984 report and persist it to `moderation_reports`. +/// +/// Rejections use client-safe `invalid:`/`restricted:` reasons. On success +/// the report is queued (idempotently, keyed by the signed event id) and the +/// event itself is **not** stored or broadcast as a regular event. +pub async fn handle_report_event( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> Result<(), String> { + let parsed = parse_report(event)?; + let reporter_pubkey = event.pubkey.to_bytes(); + + let (target, channel_id) = match parsed.target { + ParsedReportTarget::Event { event_id, .. } => { + let stored = state + .db + .get_event_by_id(tenant.community(), &event_id) + .await + .map_err(|e| format!("error: database error resolving report target: {e}"))? + .ok_or_else(|| "invalid: report target event not found".to_string())?; + (ReportTarget::Event(event_id), stored.channel_id) + } + ParsedReportTarget::Blob { sha256, .. } => { + let sha_hex = hex::encode(&sha256); + // Known Phase-1 limitation: the media sidecar API does not expose a + // cheap typed not-found vs transient-storage distinction here, so + // all lookup failures surface as a missing blob to the reporter. + state + .media_storage + .get_sidecar(tenant, &sha_hex) + .await + .map_err(|_| "invalid: report target blob not found".to_string())?; + (ReportTarget::Blob(sha256), None) + } + ParsedReportTarget::Pubkey { pubkey } => (ReportTarget::Pubkey(pubkey), None), + }; + + state + .db + .insert_moderation_report( + tenant.community(), + NewReport { + report_event_id: event.id.as_bytes(), + reporter_pubkey: &reporter_pubkey, + target, + channel_id, + report_type: parsed.report_type, + note: report_note(event), + }, + ) + .await + .map_err(|e| format!("error: database error inserting report: {e}"))?; + + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedReport<'a> { + target: ParsedReportTarget, + report_type: &'a str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ParsedReportTarget { + Event { + event_id: Vec, + /// Validation-shape only: NIP-56 requires the reported pubkey tag, but + /// e-target author truth comes from the stored tenant event row. + author_pubkey: Vec, + }, + Blob { + sha256: Vec, + /// Validation-shape only: NIP-56 requires the reported pubkey tag, but + /// blob authorship is not trusted from the reporter or inserted in v1. + author_pubkey: Vec, + }, + Pubkey { + pubkey: Vec, + }, +} + +fn parse_report(event: &Event) -> Result, String> { + let p_tags = collect_report_tags(event, "p"); + if p_tags.is_empty() { + return Err("invalid: report must include a p tag".to_string()); + } + if p_tags.len() > 1 { + return Err("invalid: report must include exactly one p tag".to_string()); + } + + let reported_pubkey = decode_32_byte_hex(p_tags[0].value, "p tag pubkey")?; + + let e_tags = collect_report_tags(event, "e"); + let x_tags = collect_report_tags(event, "x"); + if !e_tags.is_empty() && !x_tags.is_empty() { + return Err("invalid: report must target only one of e or x".to_string()); + } + if e_tags.len() > 1 { + return Err("invalid: report must include at most one e tag".to_string()); + } + if x_tags.len() > 1 { + return Err("invalid: report must include at most one x tag".to_string()); + } + + if let Some(tag) = e_tags.first() { + let report_type = parse_report_type(tag.report_type)?; + return Ok(ParsedReport { + target: ParsedReportTarget::Event { + event_id: decode_32_byte_hex(tag.value, "e tag event id")?, + author_pubkey: reported_pubkey, + }, + report_type, + }); + } + + if let Some(tag) = x_tags.first() { + let report_type = parse_report_type(tag.report_type)?; + return Ok(ParsedReport { + target: ParsedReportTarget::Blob { + sha256: decode_32_byte_hex(tag.value, "x tag sha256")?, + author_pubkey: reported_pubkey, + }, + report_type, + }); + } + + let p_tag = p_tags[0]; + let report_type = parse_report_type(p_tag.report_type)?; + Ok(ParsedReport { + target: ParsedReportTarget::Pubkey { + pubkey: reported_pubkey, + }, + report_type, + }) +} + +#[derive(Debug, Clone, Copy)] +struct ReportTag<'a> { + value: &'a str, + report_type: Option<&'a str>, +} + +fn collect_report_tags<'a>(event: &'a Event, tag_name: &str) -> Vec> { + event + .tags + .iter() + .filter_map(|tag| { + let fields = tag.as_slice(); + if fields.len() >= 2 && fields[0] == tag_name { + Some(ReportTag { + value: fields[1].as_str(), + report_type: fields.get(2).map(String::as_str), + }) + } else { + None + } + }) + .collect() +} + +fn parse_report_type(value: Option<&str>) -> Result<&str, String> { + let Some(value) = value else { + return Err("invalid: report target tag missing report type".to_string()); + }; + if REPORT_TYPES.contains(&value) { + Ok(value) + } else { + Err(format!("invalid: unsupported report type: {value}")) + } +} + +fn decode_32_byte_hex(value: &str, label: &str) -> Result, String> { + if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("invalid: malformed {label}")); + } + let bytes = hex::decode(value).map_err(|_| format!("invalid: malformed {label}"))?; + if bytes.len() != 32 { + return Err(format!("invalid: malformed {label}")); + } + Ok(bytes) +} + +fn report_note(event: &Event) -> Option<&str> { + if event.content.is_empty() { + None + } else { + Some(event.content.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::kind::KIND_REPORT; + + fn report_with_tags(tags: &[&[&str]]) -> Event { + let keys = nostr::Keys::generate(); + let tags = tags + .iter() + .map(|tag| nostr::Tag::parse(tag.iter().copied()).unwrap()) + .collect::>(); + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_REPORT as u16), "note") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + fn hex32(byte: u8) -> String { + hex::encode([byte; 32]) + } + + #[test] + fn parses_event_report_type_from_e_tag_third_element() { + let e = hex32(1); + let p = hex32(2); + let event = report_with_tags(&[&["p", &p], &["e", &e, "spam"]]); + + let parsed = parse_report(&event).unwrap(); + + assert_eq!(parsed.report_type, "spam"); + assert_eq!( + parsed.target, + ParsedReportTarget::Event { + event_id: vec![1; 32], + author_pubkey: vec![2; 32], + } + ); + } + + #[test] + fn parses_blob_report_type_from_x_tag_third_element() { + let x = hex32(3); + let p = hex32(4); + let event = report_with_tags(&[&["p", &p], &["x", &x, "malware"]]); + + let parsed = parse_report(&event).unwrap(); + + assert_eq!(parsed.report_type, "malware"); + assert_eq!( + parsed.target, + ParsedReportTarget::Blob { + sha256: vec![3; 32], + author_pubkey: vec![4; 32], + } + ); + } + + #[test] + fn parses_pubkey_only_report_type_from_p_tag_third_element() { + let p = hex32(5); + let event = report_with_tags(&[&["p", &p, "impersonation"]]); + + let parsed = parse_report(&event).unwrap(); + + assert_eq!(parsed.report_type, "impersonation"); + assert_eq!( + parsed.target, + ParsedReportTarget::Pubkey { + pubkey: vec![5; 32] + } + ); + } + + #[test] + fn rejects_reports_without_p_tag() { + let e = hex32(1); + let event = report_with_tags(&[&["e", &e, "spam"]]); + + assert_eq!( + parse_report(&event).unwrap_err(), + "invalid: report must include a p tag" + ); + } + + #[test] + fn rejects_unknown_report_type() { + let p = hex32(1); + let event = report_with_tags(&[&["p", &p, "phishing"]]); + + assert_eq!( + parse_report(&event).unwrap_err(), + "invalid: unsupported report type: phishing" + ); + } + + #[test] + fn rejects_event_and_blob_targets_together() { + let p = hex32(1); + let e = hex32(2); + let x = hex32(3); + let event = report_with_tags(&[&["p", &p], &["e", &e, "spam"], &["x", &x, "spam"]]); + + assert_eq!( + parse_report(&event).unwrap_err(), + "invalid: report must target only one of e or x" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 12ac43bc28..55cb0b1e97 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1603,6 +1603,56 @@ mod tests { (agent, owner, attacker) } + #[test] + fn push_lease_requires_self_author_filter_and_count_fallback() { + let (owner, other, _) = three_pubkeys(); + let owner_key = nostr::PublicKey::from_hex(&owner).unwrap(); + let other_key = nostr::PublicKey::from_hex(&other).unwrap(); + let own = Filter::new() + .kind(nostr::Kind::Custom(buzz_core::kind::KIND_PUSH_LEASE as u16)) + .author(owner_key); + let foreign = Filter::new() + .kind(nostr::Kind::Custom(buzz_core::kind::KIND_PUSH_LEASE as u16)) + .author(other_key); + let bare = Filter::new().kind(nostr::Kind::Custom(buzz_core::kind::KIND_PUSH_LEASE as u16)); + + assert!(author_only_filters_authorized( + std::slice::from_ref(&own), + &owner + )); + assert!(!author_only_filters_authorized(&[foreign], &owner)); + assert!(!author_only_filters_authorized(&[bare], &owner)); + assert!(filter_can_match_author_only_kinds(&own)); + } + + #[test] + fn mixed_filter_omits_another_authors_push_lease() { + let owner_keys = nostr::Keys::generate(); + let reader_keys = nostr::Keys::generate(); + let lease = nostr::EventBuilder::new( + nostr::Kind::Custom(buzz_core::kind::KIND_PUSH_LEASE as u16), + "ciphertext", + ) + .sign_with_keys(&owner_keys) + .unwrap(); + let public = nostr::EventBuilder::new(nostr::Kind::TextNote, "public") + .sign_with_keys(&owner_keys) + .unwrap(); + + assert!(is_author_only_event( + &lease, + &reader_keys.public_key().to_bytes() + )); + assert!(!is_author_only_event( + &lease, + &owner_keys.public_key().to_bytes() + )); + assert!(!is_author_only_event( + &public, + &reader_keys.public_key().to_bytes() + )); + } + #[test] fn engram_gate_allows_agent_querying_own() { let (agent, owner, _) = three_pubkeys(); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 5c898ea1eb..c3c0d6d3bc 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -14,7 +14,7 @@ use buzz_core::kind::{ KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; -use buzz_db::channel::MemberRole; +use buzz_db::channel::{MemberRecord, MemberRole}; use super::event::dispatch_persistent_event; use crate::protocol::RelayMessage; @@ -42,7 +42,9 @@ async fn evict_live_channel_subscriptions( channel_id: Uuid, target_pubkey: &[u8], ) { - let conn_ids = state.conn_manager.connection_ids_for_pubkey(target_pubkey); + let conn_ids = state + .conn_manager + .connection_ids_for_pubkey_in_community(tenant.community(), target_pubkey); for conn_id in conn_ids { evict_conn_channel_subscriptions(tenant, state, channel_id, conn_id).await; @@ -529,6 +531,11 @@ pub async fn validate_admin_event( } 9005 => { // DELETE_EVENT: event author OR channel owner/admin. + if let Some(action_id) = extract_tag_value(event, "action_id") { + Uuid::parse_str(&action_id) + .map_err(|_| anyhow::anyhow!("invalid action_id tag"))?; + } + // Extract target event from e tag to check authorship. let target_id = event .tags @@ -567,7 +574,7 @@ pub async fn validate_admin_event( // For relay-signed REST messages, the real author is in the p tag. let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); - if author == actor_bytes { + if author_delete_can_use_self_delete_path(&author, &actor_bytes, event) { // Author deleting their own message: re-gate on membership/open visibility so that // a removed private-channel member cannot mutate old messages after access is revoked. let is_member = state @@ -591,23 +598,21 @@ pub async fn validate_admin_event( // Not the author, or author who is no longer a member of a private channel — // must be owner/admin or the owning human of the message's agent-author. let members = state.db.get_members(tenant.community(), channel_id).await?; - let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); - match actor_member { - Some(m) if m.role == "owner" || m.role == "admin" => Ok(()), - _ => { - // Allow the owning human of the agent that authored the target message, - // even when the human is not a channel member. - if state - .db - .is_agent_owner(tenant.community(), &author, &actor_bytes) - .await? - { - Ok(()) - } else { - Err(anyhow::anyhow!( - "must be event author or channel owner/admin" - )) - } + if actor_is_channel_owner_or_admin(&members, &actor_bytes) { + Ok(()) + } else { + // Allow the owning human of the agent that authored the target message, + // even when the human is not a channel member. + if state + .db + .is_agent_owner(tenant.community(), &author, &actor_bytes) + .await? + { + Ok(()) + } else { + Err(anyhow::anyhow!( + "must be event author or channel owner/admin" + )) } } } @@ -1068,10 +1073,17 @@ async fn handle_agent_profile( .ok_or_else(|| anyhow::anyhow!("kind:10100 missing channel_add_policy field"))?; let pubkey_bytes = event.pubkey.to_bytes().to_vec(); - state + if state .db .ensure_user(tenant.community(), &pubkey_bytes) - .await?; + .await? + { + metrics::counter!( + "buzz_users_created_total", + "community" => tenant.host().to_owned() + ) + .increment(1); + } state .db .set_channel_add_policy(tenant.community(), &pubkey_bytes, policy) @@ -1119,10 +1131,17 @@ async fn handle_kind0_profile( let pubkey_bytes = event.pubkey.to_bytes().to_vec(); - state + if state .db .ensure_user(tenant.community(), &pubkey_bytes) - .await?; + .await? + { + metrics::counter!( + "buzz_users_created_total", + "community" => tenant.host().to_owned() + ) + .increment(1); + } // Pass all fields as Some — empty string clears the field in the DB. // This ensures kind:0 is treated as absolute state, not a partial update. @@ -1607,17 +1626,16 @@ async fn handle_delete_event_side_effect( } let actor_hex = hex::encode(event.pubkey.to_bytes()); - emit_system_message( - tenant, - state, - channel_id, - serde_json::json!({ - "type": "message_deleted", - "actor": actor_hex, - "target_event_id": hex::encode(&target_id), - }), - ) - .await?; + let mut tombstone = serde_json::json!({ + "type": "message_deleted", + "actor": actor_hex, + "target_event_id": hex::encode(&target_id), + }); + copy_optional_string_field(event, &mut tombstone, "action_id"); + copy_optional_string_field(event, &mut tombstone, "reason_code"); + copy_optional_string_field(event, &mut tombstone, "public_reason"); + + emit_system_message(tenant, state, channel_id, tombstone).await?; info!(target_event = %hex::encode(&target_id), "NIP-29 DELETE_EVENT processed"); Ok(()) @@ -1649,13 +1667,21 @@ async fn handle_create_group( // If the event has an h-tag UUID, ingest_event() already created the channel // via create_channel_with_id(). Fetch it rather than creating a duplicate. // If no h-tag, fall back to the original auto-UUID creation path. + // + // Double-count analysis (C5): the counter increments below do NOT + // double-count vs. ingest.rs. For the h-tag path, ingest increments on + // was_created=true and this handler only reaches create_channel() on a DB + // lookup Err — an error recovery path where ingest's channel is + // inaccessible, so the counter correctly records a new creation. For the + // no-h-tag path, ingest never creates the channel, so this is the sole + // increment. let channel = if let Some(client_uuid) = extract_h_tag_channel(event) { match state.db.get_channel(tenant.community(), client_uuid).await { Ok(ch) => ch, Err(_) => { // Channel not found — shouldn't happen (ingest_event pre-created it), // but fall back to creation to stay resilient. - state + let ch = state .db .create_channel( tenant.community(), @@ -1666,11 +1692,18 @@ async fn handle_create_group( &actor_bytes, ttl_seconds, ) - .await? + .await?; + metrics::counter!( + "buzz_channels_created_total", + "community" => tenant.host().to_owned(), + "type" => channel_type.to_string() + ) + .increment(1); + ch } } } else { - state + let ch = state .db .create_channel( tenant.community(), @@ -1681,7 +1714,14 @@ async fn handle_create_group( &actor_bytes, ttl_seconds, ) - .await? + .await?; + metrics::counter!( + "buzz_channels_created_total", + "community" => tenant.host().to_owned(), + "type" => channel_type.to_string() + ) + .increment(1); + ch }; // Creator becomes owner — evict any stale negative membership lookup. @@ -1944,6 +1984,10 @@ async fn handle_a_tag_deletion( let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key()); match kind_num { + // kind:30350 revocation is exclusively a higher-generation inactive replacement. + super::push_lease::KIND_PUSH_LEASE => { + tracing::debug!(d_tag, "NIP-09 deletion ignored for push lease"); + } buzz_core::kind::KIND_WORKFLOW_DEF => { // Try UUID first (workflow_id); fall back to name-based lookup. if let Ok(wf_id) = uuid::Uuid::parse_str(d_tag) { @@ -2067,6 +2111,13 @@ async fn handle_standard_deletion_event( Some(target) => target, None => continue, }; + if u32::from(target_event.event.kind.as_u16()) == super::push_lease::KIND_PUSH_LEASE { + tracing::debug!( + target_id = %hex::encode(&target_id), + "NIP-09 deletion ignored for push lease" + ); + continue; + } let meta = state .db @@ -2264,6 +2315,60 @@ fn extract_tag_value(event: &Event, tag_name: &str) -> Option { None } +fn copy_optional_string_field(event: &Event, object: &mut serde_json::Value, tag_name: &str) { + let Some(value) = extract_tag_value(event, tag_name) else { + return; + }; + copy_optional_string_value(object, tag_name, value); +} + +fn copy_optional_string_value(object: &mut serde_json::Value, field_name: &str, value: String) { + if let Some(map) = object.as_object_mut() { + map.insert(field_name.to_string(), serde_json::Value::String(value)); + } +} + +fn has_moderation_delete_metadata(event: &Event) -> bool { + ["action_id", "reason_code", "public_reason"] + .iter() + .any(|tag_name| extract_tag_value(event, tag_name).is_some()) +} + +fn author_delete_can_use_self_delete_path(author: &[u8], actor: &[u8], event: &Event) -> bool { + author == actor && !has_moderation_delete_metadata(event) +} + +fn actor_is_channel_owner_or_admin(members: &[MemberRecord], actor: &[u8]) -> bool { + members + .iter() + .any(|m| m.pubkey == actor && (m.role == "owner" || m.role == "admin")) +} + +#[cfg(test)] +fn delete_tombstone_content( + actor_hex: String, + target_event_id: String, + action_id: Option, + reason_code: Option, + public_reason: Option, +) -> serde_json::Value { + let mut tombstone = serde_json::json!({ + "type": "message_deleted", + "actor": actor_hex, + "target_event_id": target_event_id, + }); + if let Some(action_id) = action_id { + copy_optional_string_value(&mut tombstone, "action_id", action_id); + } + if let Some(reason_code) = reason_code { + copy_optional_string_value(&mut tombstone, "reason_code", reason_code); + } + if let Some(public_reason) = public_reason { + copy_optional_string_value(&mut tombstone, "public_reason", public_reason); + } + tombstone +} + /// Validate a git repo identifier (d-tag value from kind:30617). /// /// Rules: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`. @@ -2649,39 +2754,29 @@ async fn emit_initial_ref_state( /// /// Queries all current relay members and emits a relay-signed, NIP-70-protected /// addressable event listing every member pubkey. Replaces any previous list. +/// +/// The member query, event construction, and replacement all happen inside +/// the same per-community advisory lock held by +/// `publish_nip43_membership_locked`. This prevents a stale snapshot from +/// overwriting a newer one when two publications race: the lock serializes +/// the entire read-build-write cycle, not just the write. pub async fn publish_nip43_membership_list( tenant: &TenantContext, state: &Arc, ) -> anyhow::Result<()> { - let members = state.db.list_relay_members(tenant.community()).await?; let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + let community = tenant.community(); - let mut tags: Vec = Vec::with_capacity(members.len() + 1); - - // NIP-70 protected-event marker — prevents re-broadcasting by third parties. - tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); - - for member in &members { - tags.push( - Tag::parse(["member", &member.pubkey, &member.role]) - .map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?, - ); - } - - let event = EventBuilder::new(Kind::Custom(KIND_NIP43_MEMBERSHIP_LIST as u16), "") - .tags(tags) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?; - - // NOTE: kind 13534 is technically a regular event (not in the NIP-16 replaceable - // range), but we intentionally use replace_addressable_event to get replacement - // semantics — only the latest membership snapshot matters. This function keys on - // (kind, pubkey, channel_id) and atomically replaces older events, which is exactly - // what Pyramid (the reference NIP-43 implementation) does with store.ReplaceEvent(). - let (stored, was_inserted) = state + // The DB method acquires the per-community snapshot lock BEFORE reading + // members, so that the entire read-build-write cycle is serialized. A + // concurrent publication that reads newer state will block until our + // replacement commits, then read the updated membership. This prevents a + // stale snapshot from winning by arrival order. + let (stored, was_inserted, member_count) = state .db - .replace_addressable_event(tenant.community(), &event, None) + .publish_nip43_membership_locked(community, &state.relay_keypair) .await?; + if was_inserted { dispatch_persistent_event( tenant, @@ -2694,10 +2789,7 @@ pub async fn publish_nip43_membership_list( .await; } - info!( - member_count = members.len(), - "NIP-43 membership list published" - ); + info!(member_count, "NIP-43 membership list published"); Ok(()) } @@ -3089,3 +3181,86 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { None => EventTopic::Global, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn delete_tombstone_omits_absent_moderation_metadata() { + let content = + delete_tombstone_content("actor".to_string(), "target".to_string(), None, None, None); + + assert_eq!(content["type"], "message_deleted"); + assert_eq!(content["actor"], "actor"); + assert_eq!(content["target_event_id"], "target"); + assert!(content.get("action_id").is_none()); + assert!(content.get("reason_code").is_none()); + assert!(content.get("public_reason").is_none()); + } + + #[test] + fn delete_tombstone_carries_optional_moderation_metadata() { + let content = delete_tombstone_content( + "actor".to_string(), + "target".to_string(), + Some("550e8400-e29b-41d4-a716-446655440000".to_string()), + Some("spam".to_string()), + Some("Removed for spam.".to_string()), + ); + + assert_eq!(content["type"], "message_deleted"); + assert_eq!(content["actor"], "actor"); + assert_eq!(content["target_event_id"], "target"); + assert_eq!(content["action_id"], "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(content["reason_code"], "spam"); + assert_eq!(content["public_reason"], "Removed for spam."); + assert!(!content.to_string().contains("reporter")); + } + + #[test] + fn author_self_delete_with_moderation_metadata_skips_self_delete_path() { + let keys = nostr::Keys::generate(); + let actor = keys.public_key().to_bytes(); + let event = EventBuilder::new(Kind::Custom(9005), "") + .tags([Tag::parse(["public_reason", "Removed for spam."]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign"); + + assert!(!author_delete_can_use_self_delete_path( + &actor, &actor, &event + )); + } + + #[test] + fn member_role_is_not_owner_or_admin_for_moderation_metadata() { + let channel_id = Uuid::new_v4(); + let actor = vec![7_u8; 32]; + let members = vec![MemberRecord { + channel_id, + pubkey: actor.clone(), + role: "member".to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }]; + + assert!(!actor_is_channel_owner_or_admin(&members, &actor)); + } + + #[test] + fn admin_role_is_owner_or_admin_for_moderation_metadata() { + let channel_id = Uuid::new_v4(); + let actor = vec![7_u8; 32]; + let members = vec![MemberRecord { + channel_id, + pubkey: actor.clone(), + role: "admin".to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }]; + + assert!(actor_is_channel_owner_or_admin(&members, &actor)); + } +} diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs new file mode 100644 index 0000000000..dda3c495ee --- /dev/null +++ b/crates/buzz-relay/src/invite_token.rs @@ -0,0 +1,329 @@ +//! Stateless relay invite tokens. +//! +//! An invite code is a compact, URL-safe, HMAC-signed blob minted by a relay +//! admin/owner and later presented by a joining user. The relay verifies the +//! signature and expiry, then inserts the presenter into `relay_members` — +//! no server-side invite storage is required. +//! +//! ## Format +//! +//! ```text +//! code = base64url(payload_json) + "." + base64url(hmac_sha256(key, payload_json)) +//! ``` +//! +//! `payload_json` is a canonical JSON object: +//! +//! ```json +//! {"c":"","r":"member","e":1767000000,"n":""} +//! ``` +//! +//! ## Key derivation +//! +//! The HMAC key is derived from the relay's signing secret key: +//! `key = sha256(relay_secret_key_bytes || "buzz-invite-v1")`. Rotating the +//! relay keypair therefore invalidates all outstanding invites, which is the +//! intended blast-radius control for a leaked link. +//! +//! ## Security properties (and non-properties) +//! +//! - Codes are **multi-use until expiry** — there is no server-side "used" +//! bit. Default expiry is deliberately short ([`DEFAULT_INVITE_TTL_SECS`]). +//! - Codes are **community-scoped**: a code minted for community A fails +//! verification when presented to community B, even on the same deployment. +//! - Codes are **role-capped at `member`** at mint time (enforced by the mint +//! route, and re-checked here on verify so a hand-crafted payload with an +//! elevated role is rejected even if it carries a valid MAC from a future +//! buggy caller). +//! - Revocation is coarse: rotate the relay keypair, or remove the member +//! after the fact. Per-code revocation requires the future `relay_invites` +//! table increment. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hmac::{Hmac, KeyInit, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use buzz_core::tenant::CommunityId; + +type HmacSha256 = Hmac; + +/// Default invite lifetime: 72 hours. +pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60; + +/// Maximum invite lifetime a mint request may ask for: 30 days. +pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60; + +/// Maximum accepted code length (defense against absurd inputs before any +/// parsing work happens). A real code is ~200 bytes. +const MAX_CODE_LEN: usize = 1024; + +/// Domain-separation label mixed into the HMAC key derivation. +const KEY_DERIVATION_LABEL: &[u8] = b"buzz-invite-v1"; + +/// The signed payload carried inside an invite code. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct InvitePayload { + /// Community the invite admits into (UUID string form). + pub c: String, + /// Role granted on claim. Only `"member"` is valid in v1. + pub r: String, + /// Expiry as unix seconds. + pub e: u64, + /// Random nonce so identically-parameterised invites differ. + pub n: String, +} + +/// Why a code failed verification. Variants are deliberately coarse — the +/// HTTP layer maps all of them to a generic rejection so the endpoint does +/// not become an oracle for forging codes. +#[derive(Debug, PartialEq, Eq)] +pub enum InviteError { + /// Structurally invalid (bad base64, bad JSON, wrong shape, too long). + Malformed, + /// MAC did not verify. + BadSignature, + /// Signature fine, but the expiry has passed. + Expired, + /// Signature fine, but minted for a different community. + WrongCommunity, + /// Signature fine, but the role is not one this relay grants via invite. + InvalidRole, +} + +impl std::fmt::Display for InviteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + InviteError::Malformed => "malformed invite code", + InviteError::BadSignature => "invalid invite signature", + InviteError::Expired => "invite code expired", + InviteError::WrongCommunity => "invite not valid for this relay", + InviteError::InvalidRole => "invite grants an unsupported role", + }; + f.write_str(msg) + } +} + +/// Derive the invite HMAC key from the relay's signing secret. +/// +/// `sha256(secret_key_bytes || label)` — the label domain-separates this use +/// from any other HMAC built on the same keypair. +pub fn derive_invite_key(relay_keys: &nostr::Keys) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(relay_keys.secret_key().as_secret_bytes()); + hasher.update(KEY_DERIVATION_LABEL); + hasher.finalize().into() +} + +fn sign_payload(key: &[u8; 32], payload_bytes: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size"); + mac.update(payload_bytes); + mac.finalize().into_bytes().to_vec() +} + +/// Mint an invite code for `community`, expiring `ttl_secs` from now. +/// +/// The role is fixed to `"member"` — elevated roles are granted post-join via +/// the existing kind:9032 change-role command, never via a bearer link. +pub fn mint_invite(key: &[u8; 32], community: CommunityId, ttl_secs: u64) -> (String, u64) { + let ttl = ttl_secs.clamp(60, MAX_INVITE_TTL_SECS); + let expires_at = now_unix() + ttl; + + let nonce: [u8; 16] = rand::random(); + let payload = InvitePayload { + c: community.as_uuid().to_string(), + r: "member".to_string(), + e: expires_at, + n: URL_SAFE_NO_PAD.encode(nonce), + }; + let payload_bytes = serde_json::to_vec(&payload).expect("payload serializes"); + let mac = sign_payload(key, &payload_bytes); + + let code = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(&payload_bytes), + URL_SAFE_NO_PAD.encode(mac) + ); + (code, expires_at) +} + +/// Verify an invite code presented to `community`. +/// +/// Order matters: signature is checked before any claims inside the payload +/// are trusted (expiry / community / role), and errors after the MAC check +/// still return coarse variants so the endpoint stays a poor oracle. +pub fn verify_invite( + key: &[u8; 32], + community: CommunityId, + code: &str, +) -> Result { + if code.len() > MAX_CODE_LEN { + return Err(InviteError::Malformed); + } + let (payload_b64, mac_b64) = code.split_once('.').ok_or(InviteError::Malformed)?; + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload_b64) + .map_err(|_| InviteError::Malformed)?; + let mac_bytes = URL_SAFE_NO_PAD + .decode(mac_b64) + .map_err(|_| InviteError::Malformed)?; + + // Constant-time MAC verification before trusting anything in the payload. + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size"); + mac.update(&payload_bytes); + mac.verify_slice(&mac_bytes) + .map_err(|_| InviteError::BadSignature)?; + + let payload: InvitePayload = + serde_json::from_slice(&payload_bytes).map_err(|_| InviteError::Malformed)?; + + if payload.e < now_unix() { + return Err(InviteError::Expired); + } + if payload.c != community.as_uuid().to_string() { + return Err(InviteError::WrongCommunity); + } + if payload.r != "member" { + return Err(InviteError::InvalidRole); + } + Ok(payload) +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn test_key() -> [u8; 32] { + derive_invite_key(&nostr::Keys::generate()) + } + + fn community() -> CommunityId { + CommunityId::from_uuid(Uuid::new_v4()) + } + + #[test] + fn mint_then_verify_roundtrip() { + let key = test_key(); + let c = community(); + let (code, expires_at) = mint_invite(&key, c, 3600); + let payload = verify_invite(&key, c, &code).expect("valid code verifies"); + assert_eq!(payload.c, c.as_uuid().to_string()); + assert_eq!(payload.r, "member"); + assert_eq!(payload.e, expires_at); + } + + #[test] + fn rejects_wrong_community() { + let key = test_key(); + let (code, _) = mint_invite(&key, community(), 3600); + assert_eq!( + verify_invite(&key, community(), &code), + Err(InviteError::WrongCommunity) + ); + } + + #[test] + fn rejects_tampered_payload() { + let key = test_key(); + let c = community(); + let (code, _) = mint_invite(&key, c, 3600); + let (payload_b64, mac_b64) = code.split_once('.').unwrap(); + + // Re-encode a payload with an elevated role but keep the original MAC. + let mut payload: InvitePayload = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload_b64).unwrap()).unwrap(); + payload.r = "owner".to_string(); + let forged = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()), + mac_b64 + ); + assert_eq!( + verify_invite(&key, c, &forged), + Err(InviteError::BadSignature) + ); + } + + #[test] + fn rejects_wrong_key() { + let c = community(); + let (code, _) = mint_invite(&test_key(), c, 3600); + assert_eq!( + verify_invite(&test_key(), c, &code), + Err(InviteError::BadSignature) + ); + } + + #[test] + fn rejects_expired() { + let key = test_key(); + let c = community(); + // ttl clamps to 60s minimum, so hand-mint an already-expired payload. + let payload = InvitePayload { + c: c.as_uuid().to_string(), + r: "member".to_string(), + e: now_unix() - 10, + n: "n".to_string(), + }; + let bytes = serde_json::to_vec(&payload).unwrap(); + let mac = sign_payload(&key, &bytes); + let code = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(&bytes), + URL_SAFE_NO_PAD.encode(mac) + ); + assert_eq!(verify_invite(&key, c, &code), Err(InviteError::Expired)); + } + + #[test] + fn rejects_garbage() { + let key = test_key(); + let c = community(); + assert_eq!(verify_invite(&key, c, ""), Err(InviteError::Malformed)); + assert_eq!( + verify_invite(&key, c, "not-a-code"), + Err(InviteError::Malformed) + ); + // "a" / "b" are not valid base64 payloads. + assert_eq!(verify_invite(&key, c, "a.b"), Err(InviteError::Malformed)); + let huge = "x".repeat(MAX_CODE_LEN + 1); + assert_eq!(verify_invite(&key, c, &huge), Err(InviteError::Malformed)); + } + + #[test] + fn ttl_is_capped() { + let key = test_key(); + let (_, expires_at) = mint_invite(&key, community(), u64::MAX); + assert!(expires_at <= now_unix() + MAX_INVITE_TTL_SECS + 5); + } + + #[test] + fn signed_role_other_than_member_rejected() { + // Even a *correctly signed* payload with an elevated role must fail + // verification (defense against a future buggy mint caller). + let key = test_key(); + let c = community(); + let payload = InvitePayload { + c: c.as_uuid().to_string(), + r: "admin".to_string(), + e: now_unix() + 3600, + n: "n".to_string(), + }; + let bytes = serde_json::to_vec(&payload).unwrap(); + let mac = sign_payload(&key, &bytes); + let code = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(&bytes), + URL_SAFE_NO_PAD.encode(mac) + ); + assert_eq!(verify_invite(&key, c, &code), Err(InviteError::InvalidRole)); + } +} diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 088177e4de..510dced457 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -19,14 +19,18 @@ pub mod connection; pub mod error; /// WebSocket message handlers for NIP-01 client commands. pub mod handlers; -/// Relay-signed mesh-LLM status publisher. -pub mod mesh_status_publisher; +/// Stateless HMAC-signed relay invite tokens (mint/verify). +pub mod invite_token; +/// Inter-relay mesh startup wiring (`BUZZ_MESH` seam). +pub mod mesh_boot; /// Prometheus metrics: recorder, upkeep, HTTP middleware. pub mod metrics; /// NIP-11 relay information document. pub mod nip11; /// NIP-01 client/relay message parsing. pub mod protocol; +/// Durable NIP-PL matcher and delivery worker. +pub mod push_runtime; /// Axum router construction. pub mod router; /// Shared application state. @@ -37,6 +41,8 @@ pub mod subscription; pub mod telemetry; /// Row-zero host binding: resolve the request community from the connection host. pub mod tenant; +/// Relay-side tunnel session directory and routing. +pub mod tunnel; /// Webhook secret generation and constant-time comparison. pub mod webhook_secret; /// Workflow action sink — relay-side implementation of [`buzz_workflow::ActionSink`]. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 973b7f68bf..744633baa9 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1,11 +1,14 @@ +use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::sync::Arc; use tracing::{error, info, warn}; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; +use uuid::Uuid; use buzz_audit::AuditService; use buzz_auth::AuthService; +use buzz_core::CommunityId; use buzz_db::{Db, DbConfig}; use buzz_pubsub::PubSubManager; use buzz_search::SearchService; @@ -16,6 +19,7 @@ use buzz_relay::router::{build_health_router, build_router}; use buzz_relay::state::AppState; use buzz_relay::telemetry; use buzz_workflow::WorkflowEngine; +use tokio_util::sync::CancellationToken; fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool { value.map(str::trim).is_some_and(|value| { @@ -26,6 +30,54 @@ fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool { }) } +/// Controls how many per-community gauge series the usage poller emits. +/// +/// Datadog cost is proportional to the number of unique time-series. With ~25 +/// gauge label combinations per community, a relay hosting thousands of +/// communities would incur five-figure monthly costs if every community always +/// gets a full set of series. This knob is the cost lever. +/// +/// Fleet-wide totals (`buzz_total_*`) always emit regardless of mode. +/// +/// Set via `BUZZ_USAGE_METRICS_PER_COMMUNITY`: +/// - `all` — emit per-community series for every community (default) +/// - `off` — suppress all per-community series; fleet totals only +/// +/// A `top:` mode (per-community series for the k most-active communities) +/// is planned as a fast-follow once the series-lifecycle (gauge idle-timeout +/// and stable tie-breaking across pods) is fully designed. +#[derive(Debug, Clone)] +enum EmissionScope { + All, + Off, +} + +impl EmissionScope { + fn from_env() -> Self { + let raw = std::env::var("BUZZ_USAGE_METRICS_PER_COMMUNITY") + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + match raw.as_str() { + "" | "all" => EmissionScope::All, + "off" => EmissionScope::Off, + other => { + warn!( + value = other, + "BUZZ_USAGE_METRICS_PER_COMMUNITY: unknown value — defaulting to all" + ); + EmissionScope::All + } + } + } + + fn allows(&self, _community_id: &Uuid) -> bool { + matches!(self, Self::All) + } +} + +const USAGE_METRICS_LOCK_KEY: i64 = 0x4255_5A5A_4D45_5452; + #[tokio::main] async fn main() -> anyhow::Result<()> { // Install the ring CryptoProvider for rustls. Required before any rustls @@ -79,9 +131,12 @@ async fn main() -> anyhow::Result<()> { "Config loaded" ); - relay_metrics::install(config.metrics_port); + let usage_interval_secs = usage_metrics_interval_secs(); + let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); + relay_metrics::install(config.metrics_port, usage_idle_timeout_secs); info!( port = config.metrics_port, + idle_timeout_secs = usage_idle_timeout_secs, "Prometheus metrics exporter started" ); @@ -273,6 +328,12 @@ async fn main() -> anyhow::Result<()> { let pubsub_for_cache = Arc::clone(&pubsub); tokio::spawn(async move { pubsub_for_cache.run_cache_invalidation_subscriber().await }); + // Spawn Redis pub/sub subscriber for cross-pod connection-control commands. + // Bans recorded on other pods are received here and applied to any local + // sockets (via the consumer loop below), enforcing live disconnect fan-out. + let pubsub_for_conn_ctrl = Arc::clone(&pubsub); + tokio::spawn(async move { pubsub_for_conn_ctrl.run_conn_control_subscriber().await }); + let auth = AuthService::new(config.auth.clone()); // Postgres FTS: the searchable row IS the persisted event row (its @@ -334,6 +395,34 @@ async fn main() -> anyhow::Result<()> { ); let state = Arc::new(app_state); + // Inter-relay mesh (BUZZ_MESH seam). `boot_mesh` returns None when the + // kill switch is off — nothing is bound, published, or spawned, so the + // relay behaves byte-identically to a build without the mesh. When + // enabled, a misconfigured mesh is fatal here (bind/Redis failure): an + // operator who asked for the mesh gets it or gets told why not. + if let Some(handle) = buzz_relay::mesh_boot::boot_mesh( + &state.config, + state.redis_pool.clone(), + &state.relay_keypair, + Arc::clone(&state.shutting_down), + ) + .await? + { + let runtime_id = handle.local_runtime_id; + // Register the per-profile inbound consumers (huddle datagram fan-in, + // HuddleControl accept loop, reliable-stream accept + optional + // BUZZ_MESH_DEMO_ECHO) before peers can route traffic here. + handle.wire_consumers( + Arc::clone(&state.audio_rooms), + state.config.mesh_demo_echo, + Arc::clone(&state.shutting_down), + ); + if state.mesh.set(handle).is_err() { + unreachable!("mesh handle is set exactly once, right here"); + } + info!(runtime_id = %runtime_id, "Inter-relay mesh started"); + } + // Git-on-object-storage: admit the configured S3/MinIO backend against the // linearizable conditional-write axiom (A3) before serving git traffic. // Failure is fatal: a backend that cannot satisfy pointer CAS invalidates @@ -375,38 +464,31 @@ async fn main() -> anyhow::Result<()> { // NIP-43: publish the initial membership list on startup so clients can // REQ kind:13534 immediately without waiting for the next membership change. if config.require_relay_membership { - let startup_state = Arc::clone(&state); - tokio::spawn(async move { - // Resolve the deployment's community from the configured relay URL - // host (single-community per deployment), failing closed if the host - // isn't mapped — the membership list is community-scoped now, so the - // relay-signed startup publish must carry a resolved tenant. - let tenant = match buzz_relay::tenant::bind_deployment_community( - &startup_state.db, - &startup_state.config.relay_url, - ) + // Resolve the deployment's community from the configured relay URL + // host (single-community per deployment), failing closed if the host + // isn't mapped. Await publication before opening the listener so the + // first client query observes the roster. + match buzz_relay::tenant::bind_deployment_community(&state.db, &state.config.relay_url) .await - { - Ok(ctx) => ctx, - Err(e) => { - tracing::warn!( - error = ?e, - "initial NIP-43 membership list skipped: relay host is not mapped to a community" - ); - return; + { + Ok(tenant) => { + if let Err(e) = buzz_relay::handlers::side_effects::publish_nip43_membership_list( + &tenant, &state, + ) + .await + { + tracing::warn!(error = %e, "failed to publish initial NIP-43 membership list on startup"); + } else { + tracing::info!("NIP-43 membership list published on startup"); } - }; - if let Err(e) = buzz_relay::handlers::side_effects::publish_nip43_membership_list( - &tenant, - &startup_state, - ) - .await - { - tracing::warn!(error = %e, "failed to publish initial NIP-43 membership list on startup"); - } else { - tracing::info!("NIP-43 membership list published on startup"); } - }); + Err(e) => { + tracing::warn!( + error = ?e, + "initial NIP-43 membership list skipped: relay host is not mapped to a community" + ); + } + } } // Emit kind:39000/39002 discovery events for channels that exist in the DB @@ -547,6 +629,17 @@ async fn main() -> anyhow::Result<()> { }); } + // NIP-PL matcher and worker are enabled as one unit. Lease acceptance is + // already disabled without the exact gateway URL, so discovery and runtime + // cannot advertise or accumulate work for an undeliverable configuration. + if state.config.push_gateway_delivery_url.is_some() { + tokio::spawn(buzz_relay::push_runtime::run_matcher(Arc::clone(&state))); + tokio::spawn(buzz_relay::push_runtime::run_delivery_worker(Arc::clone( + &state, + ))); + info!("NIP-PL push matcher and delivery worker started"); + } + // NIP-ER reminder scheduler — polls for due reminders and publishes them // to Redis pub/sub for cross-pod fan-out. Each pod's existing // subscribe_local consumer picks them up and applies the author-only gate. @@ -710,10 +803,10 @@ async fn main() -> anyhow::Result<()> { loop { match rx.recv().await { Ok(scoped) => { - // The local moka caches key on globally-unique UUIDs / - // pubkeys, so applying the tenant-local op by key is - // correct regardless of community; the `community_id` - // scope rides the Redis topic, not the moka key. + // The Redis topic carries the originating community, + // and the local moka keys carry that same label. Apply + // only the matching tenant-local drop; a mutation in A + // must not flush B's derived state. state_for_cache .apply_cache_invalidation(scoped.community_id, scoped.invalidation); } @@ -730,6 +823,68 @@ async fn main() -> anyhow::Result<()> { }); } + // Durable lifecycle backstop: Redis pub/sub cannot deliver to a pod that was + // offline. Periodically revalidate only communities with local live sockets + // so missed archive commands still converge without a global DB scan. + { + let lifecycle_state = Arc::clone(&state); + let interval_secs = std::env::var("BUZZ_COMMUNITY_REVALIDATE_INTERVAL_SECS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(30) + .clamp(1, 300); + let cancel = lifecycle_state.community_revalidator_cancel.clone(); + tokio::spawn(run_community_revalidator( + lifecycle_state, + std::time::Duration::from_secs(interval_secs), + cancel, + )); + } + + // Cross-pod connection-control consumer: receive disconnect commands from + // Redis pub/sub (published by the pod that recorded a ban) and close any + // matching local sockets. A member's live connections may land on any pod, + // so this is how a ban reaches sockets the banning pod does not hold. The DB + // ban row is the durable backstop; even a dropped command still refuses the + // banned member's next auth attempt at the auth seam. + { + let state_for_conn_ctrl = Arc::clone(&state); + let mut rx = state_for_conn_ctrl.pubsub.subscribe_conn_control(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(scoped) => match scoped.command { + buzz_pubsub::conn_control::ConnControl::DisconnectCommunity => { + state_for_conn_ctrl + .community_connections + .disconnect_community(scoped.community_id); + } + buzz_pubsub::conn_control::ConnControl::DisconnectPubkey { + pubkey, + event_id, + reason, + } => { + state_for_conn_ctrl.conn_manager.disconnect_pubkey( + scoped.community_id, + &pubkey, + &event_id, + &reason, + ); + } + }, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + metrics::counter!("buzz_conn_control_lag_total").increment(n); + tracing::warn!("Connection-control consumer lagged by {n} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + tracing::error!("Connection-control broadcast channel closed"); + break; + } + } + } + }); + } + let router = build_router(Arc::clone(&state)); let health_router = build_health_router(Arc::clone(&state)); @@ -761,7 +916,59 @@ async fn main() -> anyhow::Result<()> { }); } + // Usage metrics: periodic background task polling per-community stats. + // + // DB-derived gauges (users, channels, messages, members, workflows, git + // repos, active users/channels) are SET from GROUP BY queries — one per + // tick. In-memory gauges (ws_connections, subscriptions, users_online) + // are snapshotted from live in-memory state. Both avoid inc/dec drift. + // + // Multi-pod semantics: + // DB-derived: all pods export the same value → dashboard uses max() + // In-memory: each pod exports its partition → dashboard uses sum() + { + let usage_state = Arc::clone(&state); + let emission_scope = EmissionScope::from_env(); + let interval_secs = usage_interval_secs; + let mut leader = None; + let mut emitted_in_memory = HashSet::new(); + tokio::spawn(async move { + // Jitter the first tick by a random fraction of the interval so + // that a rolling deploy with N pods doesn't hammer the DB + // simultaneously at boot. Each pod picks a start delay in + // [0, interval_secs) using true per-process randomness (PID-derived + // seeds are unsafe in containers where the relay is typically PID 1 + // in every pod, which would make all pods compute the same delay). + let jitter_secs = rand::random::() % interval_secs; + tokio::time::sleep(std::time::Duration::from_secs(jitter_secs)).await; + + let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); + // Skip a tick rather than scheduling a burst of catch-up ticks if + // the system falls behind (e.g. the previous tick took > interval). + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + if let Err(e) = run_usage_metrics_tick( + &usage_state, + &emission_scope, + &mut leader, + &mut emitted_in_memory, + ) + .await + { + error!(error = %e, "Usage metrics tick failed — skipping"); + } + metrics::gauge!("buzz_usage_poller_is_leader").set(if leader.is_some() { + 1.0 + } else { + 0.0 + }); + } + }); + } + serve(router, health_router, Arc::clone(&state)).await?; + state.community_revalidator_cancel.cancel(); // Signal the audit worker to stop accepting, flush buffered entries, and // exit. Uses a CancellationToken so it works regardless of how many @@ -780,6 +987,42 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +async fn run_community_revalidator( + state: Arc, + period: std::time::Duration, + cancel: CancellationToken, +) { + run_periodic_until_cancelled(period, cancel, || async { + let closed = state.revalidate_live_communities().await; + if closed > 0 { + tracing::info!( + closed, + "closed sockets for inactive communities during lifecycle revalidation" + ); + } + }) + .await; +} + +async fn run_periodic_until_cancelled( + period: std::time::Duration, + cancel: CancellationToken, + mut tick: Tick, +) where + Tick: FnMut() -> TickFuture, + TickFuture: std::future::Future, +{ + let mut interval = tokio::time::interval(period); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => break, + _ = interval.tick() => tick().await, + } + } +} + /// Bind all listeners and run with graceful shutdown. /// /// ```text @@ -926,9 +1169,556 @@ fn reminder_to_event(reminder: &buzz_db::event::DueReminder) -> nostr::Event { serde_json::from_value(event_json).expect("valid event JSON from DB row") } +/// Return the usage poll interval, with a floor that prevents a busy loop. +fn usage_metrics_interval_secs() -> u64 { + std::env::var("BUZZ_USAGE_METRICS_INTERVAL_SECS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(300) + .max(5) +} + +/// Return a gauge lifetime that always outlives several usage-poller ticks. +fn usage_metrics_idle_timeout_secs(interval_secs: u64) -> u64 { + let configured = std::env::var("BUZZ_USAGE_METRICS_IDLE_TIMEOUT_SECS") + .ok() + .and_then(|value| value.parse().ok()); + idle_timeout_secs(configured, interval_secs) +} + +fn idle_timeout_secs(configured: Option, interval_secs: u64) -> u64 { + configured + .unwrap_or(900) + .max(interval_secs.saturating_mul(3)) +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum InMemoryMetricKey { + WsConnections(String), + UsersOnline(String), + Subscriptions(String), +} + +impl InMemoryMetricKey { + fn set(&self, value: f64) { + match self { + Self::WsConnections(community) => { + metrics::gauge!("buzz_community_ws_connections", "community" => community.clone()) + .set(value); + } + Self::UsersOnline(community) => { + metrics::gauge!("buzz_community_users_online_pod", "community" => community.clone()) + .set(value); + } + Self::Subscriptions(community) => { + metrics::gauge!("buzz_community_subscriptions", "community" => community.clone()) + .set(value); + } + } + } +} + +/// Refresh the exporter recency for legacy event-driven gauges without changing +/// their values. +/// +/// `metrics-util` 0.20.4 increments the Prometheus recorder's generation on +/// every gauge operation, including `increment(0.0)`. The recency policy uses +/// that generation, so this retains a steady gauge without a snapshot `set()` +/// racing the lifecycle-relative increments and decrements. +fn refresh_legacy_active_gauge_recency() { + metrics::gauge!("buzz_ws_connections_active").increment(0.0); + metrics::gauge!("buzz_subscriptions_active").increment(0.0); +} + +/// Emit pod-local gauges and zero only label keys that disappeared since the +/// preceding tick. The key stores the resolved host label so a removed or +/// renamed community can still receive its final zero. +fn emit_in_memory_usage_metrics( + state: &AppState, + emission_scope: &EmissionScope, + host_map: Option<&HashMap>, + previously_emitted: &mut HashSet, +) { + let connections = state.conn_manager.per_community_ws_connections(); + let users_online = state.conn_manager.per_community_users_online(); + let subscriptions = state.sub_registry.per_community_subscriptions(); + let total_connections = connections.values().sum::(); + let total_subscriptions = subscriptions.values().sum::(); + + metrics::gauge!("buzz_total_ws_connections").set(total_connections as f64); + metrics::gauge!("buzz_total_users_online_pod").set(users_online.values().sum::() as f64); + metrics::gauge!("buzz_total_subscriptions").set(total_subscriptions as f64); + refresh_legacy_active_gauge_recency(); + + let Some(host_map) = host_map else { + return; + }; + + let mut current = HashSet::new(); + for (id, host) in host_map { + if !emission_scope.allows(id) { + continue; + } + let community_id = CommunityId::from_uuid(*id); + let keys_and_values = [ + ( + InMemoryMetricKey::WsConnections(host.clone()), + connections.get(&community_id).copied(), + ), + ( + InMemoryMetricKey::UsersOnline(host.clone()), + users_online.get(&community_id).copied(), + ), + ( + InMemoryMetricKey::Subscriptions(host.clone()), + subscriptions.get(&community_id).copied(), + ), + ]; + for (key, value) in keys_and_values { + if let Some(value) = value { + key.set(value as f64); + current.insert(key); + } + } + } + + for key in dropped_in_memory_keys(previously_emitted, ¤t) { + key.set(0.0); + } + *previously_emitted = current; +} + +fn dropped_in_memory_keys( + previously_emitted: &HashSet, + current: &HashSet, +) -> Vec { + previously_emitted.difference(current).cloned().collect() +} + +/// Run one usage-metrics tick. Every pod emits its own in-memory gauges, while +/// one leader owns the heavier database-derived snapshot. +async fn run_usage_metrics_tick( + state: &AppState, + emission_scope: &EmissionScope, + leader: &mut Option, + emitted_in_memory: &mut HashSet, +) -> anyhow::Result<()> { + let host_map: HashMap = match state.db.usage_community_hosts().await { + Ok(hosts) => hosts + .into_iter() + .map(|community| (community.id, community.host)) + .collect(), + Err(error) => { + if leader.is_some() { + warn!("Usage metrics leader demoting: host map collection failed"); + *leader = None; + } + emit_in_memory_usage_metrics(state, emission_scope, None, emitted_in_memory); + return Err(error.into()); + } + }; + emit_in_memory_usage_metrics(state, emission_scope, Some(&host_map), emitted_in_memory); + + let mut demoted = false; + if let Some(leader_guard) = leader.as_mut() { + if !leader_guard.is_live().await { + warn!("Usage metrics leader lock connection failed liveness check; demoting"); + *leader = None; + demoted = true; + } + } + if leader.is_none() && !demoted { + *leader = state + .db + .try_lock_usage_metrics(USAGE_METRICS_LOCK_KEY) + .await?; + if leader.is_some() { + info!("Acquired usage metrics leader lock"); + } + } + if leader.is_some() { + if let Err(error) = emit_db_usage_metrics(state, emission_scope, &host_map).await { + warn!("Usage metrics leader demoting: DB collection failed"); + *leader = None; + return Err(error); + } + } + + Ok(()) +} + +/// Emit the database-derived usage snapshot from the stable leader only. +async fn emit_db_usage_metrics( + state: &AppState, + emission_scope: &EmissionScope, + host_map: &HashMap, +) -> anyhow::Result<()> { + // --- Collect all DB results before emitting any metrics (C4) --- + // + // All `.await?` calls happen here. If any query fails the function returns + // early — no metrics are emitted for this tick — preventing a mixed + // fresh/stale snapshot where later gauges retain their last value while + // earlier ones are updated. + + let community_total = state.db.usage_community_count().await?; + let user_rows = state.db.usage_user_counts().await?; + let channel_rows = state.db.usage_channel_counts().await?; + let message_rows = state.db.usage_message_counts().await?; + let relay_member_rows = state.db.usage_relay_member_counts().await?; + let workflow_rows = state.db.usage_workflow_counts().await?; + let git_repo_rows = state.db.usage_git_repo_counts().await?; + let active_users_1d = state.db.usage_active_user_counts("1 day").await?; + let active_users_7d = state.db.usage_active_user_counts("7 days").await?; + let active_users_30d = state.db.usage_active_user_counts("30 days").await?; + let active_channels_1d = state.db.usage_active_channel_counts("1 day").await?; + let active_channels_7d = state.db.usage_active_channel_counts("7 days").await?; + + // --- Determine which community IDs receive per-community series (K1) --- + // + // `active_set` is the subset of host_map IDs that get per-community gauges + // this tick. Fleet-wide totals (buzz_total_*) always emit regardless. + let active_set: HashSet = host_map + .keys() + .filter(|id| emission_scope.allows(id)) + .copied() + .collect(); + + // --- Publish phase: emit all metrics now that every query succeeded --- + + // --- A. Adoption stocks (DB-polled) --- + + // buzz_communities_total (no tag — fleet-wide count) + metrics::gauge!("buzz_communities_total").set(community_total as f64); + + // buzz_community_users{community, type:human|agent} + // Emit from host_map so communities that have zero users still get a 0 + // rather than keeping the last nonzero value until process restart. + { + let rows: HashMap = user_rows.into_iter().map(|r| (r.community_id, r)).collect(); + // Fleet totals (always emitted). + let (total_human, total_agent): (i64, i64) = rows + .values() + .fold((0, 0), |(h, a), r| (h + r.human, a + r.agent)); + metrics::gauge!("buzz_total_users", "type" => "human").set(total_human as f64); + metrics::gauge!("buzz_total_users", "type" => "agent").set(total_agent as f64); + // Per-community series (gated by active_set). + for (&id, community) in host_map { + if !active_set.contains(&id) { + continue; + } + let (human, agent) = rows.get(&id).map(|r| (r.human, r.agent)).unwrap_or((0, 0)); + metrics::gauge!("buzz_community_users", "community" => community.clone(), "type" => "human") + .set(human as f64); + metrics::gauge!("buzz_community_users", "community" => community.clone(), "type" => "agent") + .set(agent as f64); + } + } + + // buzz_community_channels{community, type} + // Zero-fill across all (community, channel_type) pairs so a type that + // drops to zero emits 0 rather than retaining its last nonzero value. + { + const CHANNEL_TYPES: &[&str] = &["stream", "forum", "dm", "workflow"]; + let rows: HashMap<(Uuid, &str), i64> = channel_rows + .into_iter() + .filter_map(|r| { + let matched = CHANNEL_TYPES + .iter() + .find(|&&t| t == r.channel_type.as_str()) + .map(|&t| ((r.community_id, t), r.count)); + if matched.is_none() { + warn!( + channel_type = %r.channel_type, + "usage_channel_counts: unrecognised channel_type — row skipped" + ); + } + matched + }) + .collect(); + // Fleet totals (always emitted). + for &ct in CHANNEL_TYPES { + let total: i64 = host_map + .keys() + .map(|id| rows.get(&(*id, ct)).copied().unwrap_or(0)) + .sum(); + metrics::gauge!("buzz_total_channels", "type" => ct).set(total as f64); + } + // Per-community series (gated by active_set). + for (&id, community) in host_map { + if !active_set.contains(&id) { + continue; + } + for &ct in CHANNEL_TYPES { + let count = rows.get(&(id, ct)).copied().unwrap_or(0); + metrics::gauge!( + "buzz_community_channels", + "community" => community.clone(), + "type" => ct + ) + .set(count as f64); + } + } + } + + // buzz_community_messages{community} + // Emit 0 for communities with no messages so dashboards don't stale-read. + { + let rows: HashMap = message_rows + .into_iter() + .map(|r| (r.community_id, r.count)) + .collect(); + // Fleet total (always emitted). + let total: i64 = rows.values().sum(); + metrics::gauge!("buzz_total_messages").set(total as f64); + // Per-community series (gated by active_set). + for (&id, community) in host_map { + if !active_set.contains(&id) { + continue; + } + let count = rows.get(&id).copied().unwrap_or(0); + metrics::gauge!("buzz_community_messages", "community" => community.clone()) + .set(count as f64); + } + } + + // buzz_community_relay_members{community, role} + // Zero-fill across all (community, role) pairs; relay_members.role is a + // CHECK constraint over {'owner', 'admin', 'member'}. + { + const RELAY_ROLES: &[&str] = &["owner", "admin", "member"]; + let rows: HashMap<(Uuid, &str), i64> = relay_member_rows + .into_iter() + .filter_map(|r| { + let matched = RELAY_ROLES + .iter() + .find(|&&role| role == r.role.as_str()) + .map(|&role| ((r.community_id, role), r.count)); + if matched.is_none() { + warn!( + role = %r.role, + "usage_relay_member_counts: unrecognised role — row skipped" + ); + } + matched + }) + .collect(); + // Fleet totals (always emitted). + for &role in RELAY_ROLES { + let total: i64 = host_map + .keys() + .map(|id| rows.get(&(*id, role)).copied().unwrap_or(0)) + .sum(); + metrics::gauge!("buzz_total_relay_members", "role" => role).set(total as f64); + } + // Per-community series (gated by active_set). + for (&id, community) in host_map { + if !active_set.contains(&id) { + continue; + } + for &role in RELAY_ROLES { + let count = rows.get(&(id, role)).copied().unwrap_or(0); + metrics::gauge!( + "buzz_community_relay_members", + "community" => community.clone(), + "role" => role + ) + .set(count as f64); + } + } + } + + // buzz_community_workflows{community, status} + // Zero-fill across all (community, status) pairs; workflow_status is a + // DB enum: {'active', 'disabled', 'archived'}. + { + const WORKFLOW_STATUSES: &[&str] = &["active", "disabled", "archived"]; + let rows: HashMap<(Uuid, &str), i64> = workflow_rows + .into_iter() + .filter_map(|r| { + let matched = WORKFLOW_STATUSES + .iter() + .find(|&&s| s == r.status.as_str()) + .map(|&s| ((r.community_id, s), r.count)); + if matched.is_none() { + warn!( + status = %r.status, + "usage_workflow_counts: unrecognised workflow status — row skipped" + ); + } + matched + }) + .collect(); + // Fleet totals (always emitted). + for &status in WORKFLOW_STATUSES { + let total: i64 = host_map + .keys() + .map(|id| rows.get(&(*id, status)).copied().unwrap_or(0)) + .sum(); + metrics::gauge!("buzz_total_workflows", "status" => status).set(total as f64); + } + // Per-community series (gated by active_set). + for (&id, community) in host_map { + if !active_set.contains(&id) { + continue; + } + for &status in WORKFLOW_STATUSES { + let count = rows.get(&(id, status)).copied().unwrap_or(0); + metrics::gauge!( + "buzz_community_workflows", + "community" => community.clone(), + "status" => status + ) + .set(count as f64); + } + } + } + + // buzz_community_git_repos{community} + // Emit 0 for communities with no repos. + { + let rows: HashMap = git_repo_rows + .into_iter() + .map(|r| (r.community_id, r.count)) + .collect(); + // Fleet total (always emitted). + let total: i64 = rows.values().sum(); + metrics::gauge!("buzz_total_git_repos").set(total as f64); + // Per-community series (gated by active_set). + for (&id, community) in host_map { + if !active_set.contains(&id) { + continue; + } + let count = rows.get(&id).copied().unwrap_or(0); + metrics::gauge!("buzz_community_git_repos", "community" => community.clone()) + .set(count as f64); + } + } + + // --- C. Engagement — windowed DAU/WAU/MAU + active channels --- + // Emit 0 for window/type/community combos that had no activity; this + // ensures a community that was active last tick but quiet this tick reads + // 0 rather than retaining its last nonzero value. + + for (data, label) in [ + (active_users_1d, "1d"), + (active_users_7d, "7d"), + (active_users_30d, "30d"), + ] { + let rows: HashMap = data.into_iter().map(|r| (r.community_id, r)).collect(); + // Fleet totals (always emitted). + let (total_human, total_agent, total_unknown): (i64, i64, i64) = + rows.values().fold((0, 0, 0), |(h, a, u), r| { + (h + r.human, a + r.agent, u + r.unknown) + }); + metrics::gauge!("buzz_total_active_users", "window" => label, "type" => "human") + .set(total_human as f64); + metrics::gauge!("buzz_total_active_users", "window" => label, "type" => "agent") + .set(total_agent as f64); + metrics::gauge!("buzz_total_active_users", "window" => label, "type" => "unknown") + .set(total_unknown as f64); + // Per-community series (gated by active_set). + for (&id, community) in host_map { + if !active_set.contains(&id) { + continue; + } + let (human, agent, unknown) = rows + .get(&id) + .map(|r| (r.human, r.agent, r.unknown)) + .unwrap_or((0, 0, 0)); + metrics::gauge!( + "buzz_community_active_users", + "community" => community.clone(), + "window" => label, + "type" => "human" + ) + .set(human as f64); + metrics::gauge!( + "buzz_community_active_users", + "community" => community.clone(), + "window" => label, + "type" => "agent" + ) + .set(agent as f64); + metrics::gauge!( + "buzz_community_active_users", + "community" => community.clone(), + "window" => label, + "type" => "unknown" + ) + .set(unknown as f64); + } + } + + for (data, label) in [(active_channels_1d, "1d"), (active_channels_7d, "7d")] { + let rows: HashMap = data + .into_iter() + .map(|r| (r.community_id, r.count)) + .collect(); + // Fleet total (always emitted). + let total: i64 = rows.values().sum(); + metrics::gauge!("buzz_total_active_channels", "window" => label).set(total as f64); + // Per-community series (gated by active_set). + for (&id, community) in host_map { + if !active_set.contains(&id) { + continue; + } + let count = rows.get(&id).copied().unwrap_or(0); + metrics::gauge!( + "buzz_community_active_channels", + "community" => community.clone(), + "window" => label + ) + .set(count as f64); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { - use super::buzz_auto_migrate_enabled; + use std::collections::HashSet; + use std::sync::Arc; + use std::time::Duration; + + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + use super::{ + buzz_auto_migrate_enabled, dropped_in_memory_keys, idle_timeout_secs, + refresh_legacy_active_gauge_recency, run_periodic_until_cancelled, EmissionScope, + InMemoryMetricKey, + }; + use metrics::GaugeFn; + use metrics_util::{ + debugging::DebugValue, + registry::{GenerationalAtomicStorage, Registry}, + }; + + #[tokio::test(start_paused = true)] + async fn periodic_loop_exits_immediately_on_cancellation() { + let cancel = CancellationToken::new(); + let tick_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count_for_tick = Arc::clone(&tick_count); + let task_cancel = cancel.clone(); + let task = tokio::spawn(async move { + run_periodic_until_cancelled(Duration::from_secs(300), task_cancel, move || { + let count = Arc::clone(&count_for_tick); + async move { + count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + }) + .await; + }); + + tokio::task::yield_now().await; + cancel.cancel(); + tokio::time::timeout(Duration::from_millis(1), task) + .await + .expect("loop must not wait for the next interval") + .expect("loop task"); + assert!(tick_count.load(std::sync::atomic::Ordering::Relaxed) <= 1); + } #[test] fn buzz_auto_migrate_is_opt_in() { @@ -944,4 +1734,77 @@ mod tests { assert!(buzz_auto_migrate_enabled(Some("yes"))); assert!(buzz_auto_migrate_enabled(Some("on"))); } + + #[test] + fn test_emission_scope_off_disallows_every_community() { + assert!(EmissionScope::All.allows(&Uuid::new_v4())); + assert!(!EmissionScope::Off.allows(&Uuid::new_v4())); + } + + #[test] + fn test_dropped_in_memory_keys_preserves_resolved_host_label() { + let previous = HashSet::from([ + InMemoryMetricKey::WsConnections("removed.example".to_owned()), + InMemoryMetricKey::UsersOnline("live.example".to_owned()), + ]); + let current = HashSet::from([InMemoryMetricKey::UsersOnline("live.example".to_owned())]); + + assert_eq!( + dropped_in_memory_keys(&previous, ¤t), + vec![InMemoryMetricKey::WsConnections( + "removed.example".to_owned() + )] + ); + } + + #[test] + fn test_legacy_gauge_recency_refresh_preserves_lifecycle_deltas() { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + let connections = metrics::gauge!("buzz_ws_connections_active"); + let subscriptions = metrics::gauge!("buzz_subscriptions_active"); + connections.increment(1.0); + subscriptions.increment(1.0); + + refresh_legacy_active_gauge_recency(); + + connections.decrement(1.0); + subscriptions.increment(1.0); + }); + + let values = snapshotter + .snapshot() + .into_vec() + .into_iter() + .map(|(key, _, _, value)| { + let DebugValue::Gauge(value) = value else { + panic!("{} must be a gauge", key.key().name()); + }; + (key.key().name().to_owned(), value.into_inner()) + }) + .collect::>(); + + assert_eq!(values.get("buzz_ws_connections_active"), Some(&0.0)); + assert_eq!(values.get("buzz_subscriptions_active"), Some(&2.0)); + } + + #[test] + fn test_legacy_gauge_recency_refresh_advances_generation() { + let registry = Registry::new(GenerationalAtomicStorage::atomic()); + let key = metrics::Key::from_name("legacy"); + let gauge = registry.get_or_create_gauge(&key, Clone::clone); + gauge.increment(1.0); + let generation_before = gauge.get_generation(); + gauge.increment(0.0); + + assert!(gauge.get_generation() > generation_before); + } + + #[test] + fn test_idle_timeout_is_at_least_three_usage_intervals() { + assert_eq!(idle_timeout_secs(None, 300), 900); + assert_eq!(idle_timeout_secs(Some(10), 1_000), 3_000); + } } diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs new file mode 100644 index 0000000000..20e550aa08 --- /dev/null +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -0,0 +1,750 @@ +//! Relay startup wiring for the inter-relay mesh (`BUZZ_MESH` seam). +//! +//! [`boot_mesh`] is the ONLY place the relay constructs mesh machinery. It +//! returns `None` — and touches nothing — when `BUZZ_MESH=off`, so mesh-off +//! deployments stay byte-identical to a relay built before this module +//! existed. When enabled, it: +//! +//! 1. binds the iroh endpoint on `BUZZ_MESH_BIND_ADDR` (boot-unique keypair = +//! boot-unique `RuntimeId`), +//! 2. publishes a relay-key-attested [`ReadyRecord`] to the Redis ready +//! registry and starts the readiness-gated heartbeat, +//! 3. starts the [`MeshRuntime`] loops (accept, reconcile/dial, gossip) and +//! runs one immediate reconcile pass so seed peers are dialed at boot, +//! 4. spawns a drain watcher: when the relay's `shutting_down` flag flips, +//! membership gossips `draining=true`, locally-owned huddle leases are +//! generation-fenced drained, and the heartbeat clears the registry record. +//! +//! Consumers (huddle control plane, reliable-stream tunnels) reach the mesh +//! exclusively through [`MeshHandle`] via `AppState::mesh()` — `None` means +//! "behave exactly like a single-instance relay." + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; + +use buzz_relay_mesh::endpoint::MeshEndpoint; +use buzz_relay_mesh::gossip::GossipRecord; +use buzz_relay_mesh::registry::{ReadyRecord, ReadyRegistry}; +use buzz_relay_mesh::{ + GoodbyeReason, InboundHandler, MeshDatagram, MeshMembership, MeshRuntime, MeshStatus, + MeshStream, Profile, RelayMeshMembership, RelayPeerTransport, RuntimeId, StreamHello, + StreamRole, +}; + +use crate::config::Config; +use crate::tunnel::directory::SessionDirectory; +use crate::tunnel::reliable::{ReliableFrame, ReliableInbound, ReliableStreamRouter}; + +/// Handler for one inbound session-stream profile. Called on the accept task; +/// implementations must hand off promptly (spawn) rather than block. +pub type SessionStreamHandler = Box; + +/// Handler for inbound realtime-media datagrams. +pub type DatagramHandler = Box; + +/// The single [`InboundHandler`] slot owner: fans inbound mesh traffic out to +/// per-profile consumers. +/// +/// The transport has exactly one inbound slot (`set_inbound`), but two lanes +/// consume session streams (`HuddleControl`, `ReliableStream`) and one +/// consumes datagrams (`RealtimeMedia`). This dispatcher is installed once by +/// [`boot_mesh`]; consumers register their entrypoints afterwards via the +/// `register_*` methods on [`MeshHandle`]'s dispatcher. Traffic arriving +/// before a slot is registered is logged and dropped — a bounded boot-window +/// race; fencing makes the peer's retry safe. +#[derive(Clone, Default)] +pub struct MeshInboundDispatcher { + slots: Arc, +} + +#[derive(Default)] +struct DispatcherSlots { + huddle_control: OnceLock, + reliable_stream: OnceLock, + datagrams: OnceLock, +} + +impl MeshInboundDispatcher { + /// Register the `HuddleControl` session-stream consumer (huddle join lane). + /// First registration wins; later calls are logged and ignored. + pub fn register_huddle_control(&self, handler: SessionStreamHandler) { + if self.slots.huddle_control.set(handler).is_err() { + tracing::warn!("mesh dispatcher: huddle_control handler already registered — ignored"); + } + } + + /// Register the `ReliableStream` session-stream consumer (goose/berd lane). + pub fn register_reliable_stream(&self, handler: SessionStreamHandler) { + if self.slots.reliable_stream.set(handler).is_err() { + tracing::warn!("mesh dispatcher: reliable_stream handler already registered — ignored"); + } + } + + /// Register the realtime-media datagram consumer (huddle audio fan-out). + pub fn register_datagrams(&self, handler: DatagramHandler) { + if self.slots.datagrams.set(handler).is_err() { + tracing::warn!("mesh dispatcher: datagram handler already registered — ignored"); + } + } +} + +impl InboundHandler for MeshInboundDispatcher { + fn on_datagram(&self, from: RuntimeId, dgram: MeshDatagram) { + match self.slots.datagrams.get() { + Some(handler) => handler(from, dgram), + None => tracing::warn!( + peer = %from, + "mesh dispatcher: datagram before handler registration — dropped" + ), + } + } + + fn on_session_stream(&self, from: RuntimeId, hello: StreamHello, stream: MeshStream) { + let StreamRole::Session { profile, .. } = &hello.role else { + // Control streams are consumed inside the runtime and never reach + // the inbound slot; anything else here is a peer bug. + tracing::warn!(peer = %from, "mesh dispatcher: non-session stream role — dropped"); + return; + }; + let slot = match profile { + Profile::HuddleControl => &self.slots.huddle_control, + Profile::ReliableStream => &self.slots.reliable_stream, + Profile::RealtimeMedia => { + // Datagram-only profile: a *stream* claiming it is a protocol + // violation, never a valid session. + tracing::warn!( + peer = %from, + "mesh dispatcher: RealtimeMedia arrived as a stream (datagram-only profile) — rejected" + ); + return; + } + }; + match slot.get() { + Some(handler) => handler(from, hello, stream), + None => tracing::warn!( + peer = %from, + ?profile, + "mesh dispatcher: session stream before handler registration — dropped" + ), + } + } +} + +/// Everything a mesh consumer needs, as one bundle. +#[derive(Clone)] +pub struct MeshHandle { + /// Redis fenced session directory — the ownership arbiter. + pub directory: SessionDirectory, + /// Fenced byte transport to peer runtimes. + pub transport: Arc, + /// Routing hints: who is alive / draining / dialable. + pub membership: Arc, + /// This runtime's boot-unique mesh identity. + pub local_runtime_id: RuntimeId, + /// Per-profile inbound registration: consumers call + /// `dispatcher.register_*` to receive their profile's traffic. The + /// dispatcher itself is already installed as the transport's single + /// inbound slot by [`boot_mesh`]. + pub dispatcher: MeshInboundDispatcher, + /// The single local generation floor for huddle audio. Owned here and + /// shared so the datagram receive path ([`MeshAudioRouter`], constructed + /// with this same `Arc` by [`wire_mesh_consumers`]) and the non-owner + /// teardown path (`audio::handler`) consult and clear ONE floor — a + /// private floor per consumer would let a torn-down session keep + /// suppressing a rejoin. It is a *local stale-frame guard only*: Redis + /// fenced CAS remains the ownership arbiter; `forget` clears local + /// suppression after teardown and never authorizes ownership. + /// + /// [`MeshAudioRouter`]: crate::audio::mesh::MeshAudioRouter + pub audio_fence: Arc, + /// The running mesh (status snapshots, shutdown). + runtime: MeshRuntime, + /// Per-room huddle owner-lease coordination. Shared with the WS-join owner + /// path (via `AppState::mesh()`) so the connection that wins the Redis CAS + /// installs one renewer per room, and with the `HuddleControlAcceptor` (via + /// [`Self::wire_consumers`]) so inbound control loops fan that renewer's + /// owner-loss signal. One registry per pod; single source of owner truth. + pub owners: Arc, +} + +impl MeshHandle { + /// Live `/_mesh` status snapshot. + pub fn status(&self) -> MeshStatus { + self.runtime.membership().status() + } + + /// Register the per-profile inbound consumers on this handle's dispatcher. + /// + /// Called once from `main.rs` right after [`boot_mesh`]; see + /// [`wire_mesh_consumers`] for what gets wired. + pub fn wire_consumers( + &self, + rooms: Arc, + demo_echo: bool, + shutting_down: Arc, + ) { + wire_mesh_consumers( + &self.dispatcher, + self.directory.clone(), + Arc::clone(&self.transport), + self.local_runtime_id, + Arc::clone(&self.audio_fence), + rooms, + Arc::clone(&self.owners), + demo_echo, + shutting_down, + ) + } +} + +/// Wire the three inbound mesh lanes to their consumers. +/// +/// - `RealtimeMedia` datagrams → [`MeshAudioRouter`] fan-in, constructed with +/// the handle's shared `audio_fence` so the datagram hot path and huddle +/// teardown (`GenerationFloor::forget`) enforce exactly one floor. +/// - `HuddleControl` streams → [`HuddleControlAcceptor::accept_inbound`] +/// (owner-side register/unregister control loop). +/// - `ReliableStream` streams → [`ReliableStreamRouter::accept_inbound`], then +/// either the `BUZZ_MESH_DEMO_ECHO` consumer (testbed evidence runs: echo +/// every validated `Data` frame back) or accept/log/close (default — no +/// product session consumer is wired yet). +/// +/// Owner-side lease renewal is NOT spawned here: `spawn_observable_renewer` +/// attaches on the *join* path's [`ReliableJoin::Owned`] arm (the pod that +/// acquires the lease renews it), which lands with the first product session +/// consumer. Inbound acceptance never owns a lease, so it never renews one. +/// +/// Takes parts rather than `&MeshHandle` so tests can wire a dispatcher +/// without standing up a live `MeshRuntime`. +/// +/// [`MeshAudioRouter`]: crate::audio::mesh::MeshAudioRouter +/// [`HuddleControlAcceptor::accept_inbound`]: crate::audio::join::HuddleControlAcceptor::accept_inbound +/// [`ReliableJoin::Owned`]: crate::tunnel::reliable::ReliableJoin::Owned +#[allow(clippy::too_many_arguments)] // boot-only parts bundle, one caller + tests +pub fn wire_mesh_consumers( + dispatcher: &MeshInboundDispatcher, + directory: SessionDirectory, + transport: Arc, + local_runtime_id: RuntimeId, + audio_fence: Arc, + rooms: Arc, + owners: Arc, + demo_echo: bool, + shutting_down: Arc, +) { + // RealtimeMedia datagrams: huddle media fan-in over the shared fence. + // `on_media_datagram` is synchronous and non-blocking (fence check + + // local room delivery), so it runs inline on the accept task. + let audio_router = crate::audio::mesh::MeshAudioRouter::with_fence( + Arc::clone(&rooms), + local_runtime_id, + audio_fence, + ); + dispatcher.register_datagrams(Box::new(move |_from, dgram| { + audio_router.on_media_datagram(&dgram); + })); + + // HuddleControl streams: owner-side peer registration for cross-pod + // huddles. The acceptor validates structurally, then Redis-fences every + // stateful frame in its control loop. + let acceptor = Arc::new(crate::audio::join::HuddleControlAcceptor::new( + rooms, + Arc::clone(&transport), + Arc::new(directory.clone()), + local_runtime_id, + Arc::clone(&owners), + )); + dispatcher.register_huddle_control(Box::new(move |from, hello, stream| { + let acceptor = Arc::clone(&acceptor); + tokio::spawn(async move { + // The acceptor reads this room's owner-loss signal from the shared + // `HuddleOwnerRegistry` (installed by the local `LocalOwner` join + // arm). Inbound acceptance owns no lease of its own; it only fans a + // lease this pod owns into the control loop it serves. + if let Err(e) = acceptor.accept_inbound(from, hello, stream).await { + tracing::warn!(peer = %from, "huddle-control stream ended with error: {e}"); + } + }); + })); + + // ReliableStream streams: owner-side accept. Every session is fence- + // validated on accept; what happens after depends on the wired consumer. + let reliable = Arc::new(ReliableStreamRouter::new( + directory, + transport, + local_runtime_id, + )); + dispatcher.register_reliable_stream(Box::new(move |from, hello, stream| { + let router = Arc::clone(&reliable); + let shutting_down = Arc::clone(&shutting_down); + tokio::spawn(async move { + let inbound = match router.accept_inbound(from, hello, stream).await { + Ok(inbound) => inbound, + Err(e) => { + tracing::warn!(peer = %from, "reliable stream rejected: {e}"); + return; + } + }; + if demo_echo { + run_demo_echo(router.directory().clone(), inbound, shutting_down).await; + } else { + tracing::info!( + session_id = %inbound.fenced.session_id, + peer = %inbound.from, + "reliable stream accepted; no session consumer wired — closing" + ); + } + }); + })); +} + +/// `BUZZ_MESH_DEMO_ECHO` consumer: echo every validated `Data` frame back to +/// the sender. A transport/session-routing smoke for cross-pod evidence runs — +/// each echoed frame proves fenced validation (Redis directory hit included) +/// on the owner and delivery in both mesh directions. Not a product flow. +/// `pub(crate)` so the join-side probe (`api::mesh_demo`) can exercise the +/// real consumer in its round-trip test. +pub(crate) async fn run_demo_echo( + directory: SessionDirectory, + inbound: ReliableInbound, + shutting_down: Arc, +) { + let session_id = inbound.fenced.session_id; + let peer = inbound.from; + let mut stream = inbound.stream; + tracing::info!(%session_id, %peer, "mesh demo echo: session open"); + let mut drain_tick = tokio::time::interval(std::time::Duration::from_millis(100)); + loop { + let frame = tokio::select! { + _ = drain_tick.tick() => { + if shutting_down.load(Ordering::Relaxed) { + if let Some(community_id) = stream.community_id() { + if let Err(e) = stream.send_goodbye(community_id, GoodbyeReason::Draining).await { + tracing::warn!(%session_id, "mesh demo echo: draining goodbye failed: {e}"); + } else { + tracing::info!(%session_id, "mesh demo echo: sent draining goodbye"); + } + } else { + let _ = stream.finish(); + tracing::info!(%session_id, "mesh demo echo: drain before community latch — closing"); + } + return; + } + continue; + } + frame = stream.recv_validated(&directory) => frame, + }; + match frame { + Ok(Some(ReliableFrame::Data(payload))) => { + // recv_validated latched the community from the frame it just + // validated, so it is always present here. + let Some(community_id) = stream.community_id() else { + tracing::warn!(%session_id, "demo echo: no community after validated Data"); + return; + }; + if let Err(e) = stream.send_bytes(community_id, &payload).await { + tracing::warn!(%session_id, "demo echo: send failed: {e}"); + return; + } + } + Ok(Some(ReliableFrame::Goodbye(reason))) => { + tracing::info!(%session_id, ?reason, "mesh demo echo: goodbye"); + return; + } + Ok(None) => { + tracing::info!(%session_id, "mesh demo echo: stream closed"); + return; + } + Err(e) => { + tracing::warn!(%session_id, "mesh demo echo: recv failed: {e}"); + return; + } + } + } +} + +/// Wire protocol version advertised in registry/gossip records. +const PROTO_VERSION: u16 = buzz_relay_mesh::WIRE_VERSION as u16; + +/// Capabilities advertised by this build. All three tunnel profiles ship in +/// the same binary, so the list is static. +fn capabilities() -> Vec { + vec![ + "reliable-stream".to_string(), + "realtime-media".to_string(), + "huddle-control".to_string(), + ] +} + +/// Addresses peers should dial, in preference order: +/// `BUZZ_MESH_ADVERTISE_ADDR` (explicit, classic-LB shapes) → +/// `POD_IP` + actual bound port (k8s Downward API, zero RBAC) → +/// every IP transport addr the endpoint reports (dev/local). +fn advertise_addrs(endpoint: &MeshEndpoint) -> Vec { + if let Ok(addr) = std::env::var("BUZZ_MESH_ADVERTISE_ADDR") { + let addr = addr.trim().to_string(); + if !addr.is_empty() { + return vec![addr]; + } + } + + let ip_addrs = endpoint.ip_addrs(); + let bound_port = ip_addrs.first().map(|sock| sock.port()).unwrap_or(0); + + if let Ok(pod_ip) = std::env::var("POD_IP") { + let pod_ip = pod_ip.trim(); + if !pod_ip.is_empty() && bound_port != 0 { + return vec![format!("{pod_ip}:{bound_port}")]; + } + } + + ip_addrs.iter().map(|sock| sock.to_string()).collect() +} + +/// Boot the mesh, or return `None` when `BUZZ_MESH=off`. +/// +/// Never fatal to relay startup by policy? No — a *misconfigured* enabled mesh +/// fails loudly (bind failure, Redis unreachable at publish). An operator who +/// sets `BUZZ_MESH=on` wants the mesh or wants to know why not; silently +/// booting meshless would be the same class of bug as silently dropping to a +/// default tenant. +pub async fn boot_mesh( + config: &Config, + redis_pool: deadpool_redis::Pool, + relay_keypair: &nostr::Keys, + shutting_down: Arc, +) -> anyhow::Result> { + if !config.mesh.enabled { + tracing::info!("mesh disabled (BUZZ_MESH is not 'on') — single-instance behavior"); + return Ok(None); + } + + let endpoint = MeshEndpoint::bind(config.mesh.bind_addr) + .await + .map_err(|e| { + anyhow::anyhow!( + "mesh endpoint bind on {} failed: {e}", + config.mesh.bind_addr + ) + })?; + let runtime_id = endpoint.runtime_id(); + let addrs = advertise_addrs(&endpoint); + tracing::info!( + runtime_id = %runtime_id, + bind_addr = %config.mesh.bind_addr, + advertise_addrs = ?addrs, + "mesh endpoint bound" + ); + + let mut local_record = GossipRecord::new(runtime_id, addrs.clone(), PROTO_VERSION); + local_record.capabilities = capabilities(); + // Anchor ready-record acceptance to this deployment's relay identity: all + // pods share the relay signing key, so a seed attested by any other key is + // foreign and rejected (Wren's review — possession is not authorization). + let membership = MeshMembership::new(local_record) + .with_expected_relay_pubkey(relay_keypair.public_key().to_hex()); + + let registry = ReadyRegistry::new(redis_pool.clone(), config.mesh.registry_refresh); + let ready_record = ReadyRecord::new( + runtime_id, + relay_keypair, + addrs, + PROTO_VERSION, + capabilities(), + ); + + // First publish is part of boot: if Redis can't take the attested record, + // peers can never find us — fail loudly now, not quietly forever. + registry + .publish_ready(&ready_record) + .await + .map_err(|e| anyhow::anyhow!("mesh ready-registry publish failed: {e}"))?; + tracing::info!(runtime_id = %runtime_id, "mesh ready record published"); + + // Readiness-gated heartbeat: publishes while the relay would pass + // readiness, clears the record on ready→not-ready and on shutdown. + let hb_flag = Arc::clone(&shutting_down); + buzz_relay_mesh::runtime::spawn_registry_heartbeat( + registry.clone(), + ready_record, + Arc::new(move || !hb_flag.load(Ordering::Relaxed)), + ); + + let runtime = MeshRuntime::start(endpoint, membership, Some(registry)); + let owners = Arc::new(crate::audio::join::HuddleOwnerRegistry::new()); + // Dial seed peers now rather than waiting for the first reconcile tick. + runtime.reconcile_now().await; + + // Drain watcher: SIGTERM flips `shutting_down`; gossip `draining=true` so + // peers stop routing new sessions here, then actively drain locally-owned + // huddles so clients rejoin and Redis leases release inside the graceful window. + { + let runtime = runtime.clone(); + let owners = Arc::clone(&owners); + let flag = shutting_down; + tokio::spawn(async move { + loop { + if flag.load(Ordering::Relaxed) { + runtime.membership().begin_drain(); + let huddle_drained = owners.drain_all(); + tracing::info!( + huddle_drained, + "mesh drain started (draining=true gossiped)" + ); + return; + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + }); + } + + let membership_arc: Arc = Arc::new(runtime.membership().clone()); + let transport: Arc = Arc::new(runtime.clone()); + + // Install the profile dispatcher as the transport's single inbound slot. + // Consumers (huddle control, reliable-stream) register their entrypoints + // on the handle's dispatcher after AppState wiring. + let dispatcher = MeshInboundDispatcher::default(); + transport.set_inbound(Box::new(dispatcher.clone())); + + Ok(Some(MeshHandle { + directory: SessionDirectory::new(redis_pool), + transport, + membership: membership_arc, + local_runtime_id: runtime_id, + dispatcher, + audio_fence: Arc::new(crate::audio::mesh::GenerationFloor::new()), + runtime, + owners, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// BUZZ_MESH=off must be a hard no-op: no endpoint bind, no Redis write, + /// no background task — `boot_mesh` returns `None` before touching + /// anything. The Redis pool here points nowhere routable; if the off path + /// ever reached Redis this test would hang/fail. + #[tokio::test] + async fn mesh_off_boots_nothing() { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.mesh.enabled = false; + let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1") // unroutable + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let keys = nostr::Keys::generate(); + let handle = boot_mesh(&config, pool, &keys, Arc::new(AtomicBool::new(false))) + .await + .expect("off path is never an error"); + assert!(handle.is_none()); + } + + /// Blocker fix (Wren review of 8b077fdb): absent `BUZZ_MESH`, the mesh is + /// OFF — an env-untouched image upgrade must not bind or write Redis. + #[test] + fn mesh_defaults_off_when_env_absent() { + // `Config::from_env` in the test env has no BUZZ_MESH set unless a + // caller exported it; assert the fail-safe reading. + if std::env::var("BUZZ_MESH").is_ok() { + return; // externally forced — skip rather than assert a lie + } + let config = crate::config::Config::from_env().expect("default config loads"); + assert!(!config.mesh.enabled, "BUZZ_MESH absent must mean mesh off"); + } + + use std::sync::Mutex; + + use buzz_relay_mesh::{ + BoxFuture, FencedHeader, MeshError, MeshStreamFrame, StreamRecvHalf, StreamSendHalf, + }; + + struct StubSend; + impl StreamSendHalf for StubSend { + fn send_frame(&mut self, _frame: MeshStreamFrame) -> BoxFuture<'_, Result<(), MeshError>> { + Box::pin(async { Ok(()) }) + } + fn finish(&mut self) -> Result<(), MeshError> { + Ok(()) + } + } + struct StubRecv; + impl StreamRecvHalf for StubRecv { + fn recv_frame(&mut self) -> BoxFuture<'_, Result, MeshError>> { + Box::pin(async { Ok(None) }) + } + } + + fn stub_stream() -> MeshStream { + MeshStream::new(Box::new(StubSend), Box::new(StubRecv)) + } + + fn rid(byte: u8) -> RuntimeId { + RuntimeId([byte; 32]) + } + + fn session_hello(sender: RuntimeId, profile: Profile) -> StreamHello { + StreamHello { + sender, + role: buzz_relay_mesh::StreamRole::Session { + fenced: FencedHeader { + session_id: uuid::Uuid::nil(), + generation: 1, + owner_runtime_id: sender, + }, + profile, + }, + } + } + + #[test] + fn dispatcher_routes_session_streams_by_profile() { + let dispatcher = MeshInboundDispatcher::default(); + let huddle_hits: Arc>> = Arc::new(Mutex::new(vec![])); + let reliable_hits: Arc>> = Arc::new(Mutex::new(vec![])); + + let h = Arc::clone(&huddle_hits); + dispatcher.register_huddle_control(Box::new(move |from, _hello, _stream| { + h.lock().unwrap().push(from); + })); + let r = Arc::clone(&reliable_hits); + dispatcher.register_reliable_stream(Box::new(move |from, _hello, _stream| { + r.lock().unwrap().push(from); + })); + + dispatcher.on_session_stream( + rid(1), + session_hello(rid(1), Profile::HuddleControl), + stub_stream(), + ); + dispatcher.on_session_stream( + rid(2), + session_hello(rid(2), Profile::ReliableStream), + stub_stream(), + ); + // Datagram-only profile arriving as a stream: rejected, routed nowhere. + dispatcher.on_session_stream( + rid(3), + session_hello(rid(3), Profile::RealtimeMedia), + stub_stream(), + ); + + assert_eq!(*huddle_hits.lock().unwrap(), vec![rid(1)]); + assert_eq!(*reliable_hits.lock().unwrap(), vec![rid(2)]); + } + + #[test] + fn dispatcher_drops_traffic_before_registration_and_keeps_first_handler() { + let dispatcher = MeshInboundDispatcher::default(); + + // Pre-registration traffic must not panic — logged and dropped. + dispatcher.on_session_stream( + rid(1), + session_hello(rid(1), Profile::HuddleControl), + stub_stream(), + ); + dispatcher.on_datagram( + rid(1), + MeshDatagram { + fenced: FencedHeader { + session_id: uuid::Uuid::nil(), + generation: 1, + owner_runtime_id: rid(1), + }, + seq: 0, + payload: vec![], + }, + ); + + let first: Arc> = Arc::new(Mutex::new(0)); + let f = Arc::clone(&first); + dispatcher.register_datagrams(Box::new(move |_, _| *f.lock().unwrap() += 1)); + // Second registration is ignored; the first handler keeps the slot. + dispatcher.register_datagrams(Box::new(|_, _| panic!("second handler must not win"))); + + dispatcher.on_datagram( + rid(2), + MeshDatagram { + fenced: FencedHeader { + session_id: uuid::Uuid::nil(), + generation: 1, + owner_runtime_id: rid(2), + }, + seq: 1, + payload: vec![], + }, + ); + assert_eq!(*first.lock().unwrap(), 1); + } + + /// The load-bearing wiring invariant: the datagram consumer registered by + /// `wire_mesh_consumers` enforces the SAME `GenerationFloor` that + /// `MeshHandle.audio_fence` exposes to huddle teardown. A datagram + /// arriving through the dispatcher must advance the shared floor so a + /// later `forget` on the handle's fence actually clears the hot path's + /// suppression state. + #[tokio::test] + async fn wired_datagram_consumer_shares_the_handle_fence() { + struct NoopTransport; + impl buzz_relay_mesh::RelayPeerTransport for NoopTransport { + fn send_datagram(&self, _to: RuntimeId, _dgram: MeshDatagram) -> Result<(), MeshError> { + Ok(()) + } + fn open_session_stream( + &self, + _to: RuntimeId, + _hello: StreamHello, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async { Err(MeshError::Transport("unused".into())) }) + } + fn set_inbound(&self, _handler: Box) {} + } + + let dispatcher = MeshInboundDispatcher::default(); + let fence = Arc::new(crate::audio::mesh::GenerationFloor::new()); + let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1") // never dialed + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + wire_mesh_consumers( + &dispatcher, + SessionDirectory::new(pool), + Arc::new(NoopTransport), + rid(9), + Arc::clone(&fence), + Arc::new(crate::audio::AudioRoomManager::new()), + Arc::new(crate::audio::join::HuddleOwnerRegistry::new()), + false, + Arc::new(AtomicBool::new(false)), + ); + + let session = uuid::Uuid::new_v4(); + dispatcher.on_datagram( + rid(1), + MeshDatagram { + fenced: FencedHeader { + session_id: session, + generation: 7, + owner_runtime_id: rid(9), + }, + seq: 0, + payload: vec![0, 1, 2], + }, + ); + + // The shared fence observed the datagram's generation: a stale check + // through the HANDLE's Arc is rejected, proving one floor, not two. + assert_eq!( + fence.check(session, 6), + crate::audio::mesh::FenceVerdict::RejectStale { known: 7 } + ); + // And `forget` through the handle's Arc clears the hot path's floor. + fence.forget(session); + assert!(matches!( + fence.check(session, 1), + crate::audio::mesh::FenceVerdict::Accept { .. } + )); + } +} diff --git a/crates/buzz-relay/src/mesh_status_publisher.rs b/crates/buzz-relay/src/mesh_status_publisher.rs deleted file mode 100644 index 4285e72211..0000000000 --- a/crates/buzz-relay/src/mesh_status_publisher.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! Relay-signed mesh-LLM status publication. -//! -//! The relay owns the Nostr publication surface for Buzz mesh status. mesh-llm -//! remains the source of live runtime truth, but the relay sanitizes that status -//! and republishes a member-readable, relay-signed parameterized event. This -//! keeps discovery inside Buzz's relay-membership boundary and avoids mesh's -//! public-relay publisher path. - -use std::sync::Arc; - -use buzz_core::kind::KIND_MESH_LLM_RELAY_STATUS; -use nostr::{EventBuilder, Kind, Tag}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tracing::info; - -use crate::handlers::event::dispatch_persistent_event; -use crate::state::AppState; -use buzz_core::tenant::TenantContext; - -/// d-tag prefix for the relay's mesh status events. The full d-tag is -/// `":"` so each reporting member gets an isolated, -/// independently-replaceable kind:30621 note (NIP-33 keys on `(kind, pubkey, -/// d_tag)`, and the author is always the relay key here). This avoids two -/// members' reports clobbering each other and sidesteps any read-modify-write -/// race on a single shared note. Discovery reads all notes with the `k` tag. -pub const MESH_STATUS_D_TAG_PREFIX: &str = "buzz-relay-mesh"; - -/// Build the per-reporter d-tag for a mesh status note. -pub fn mesh_status_d_tag(reporter_pubkey_hex: &str) -> String { - format!("{MESH_STATUS_D_TAG_PREFIX}:{reporter_pubkey_hex}") -} - -/// Content schema discriminator. -pub const MESH_STATUS_TYPE: &str = "buzz-mesh-status"; - -/// Sanitized relay-published mesh status. -/// -/// Carries dial metadata (mesh identity + per-target `endpoint_addr` invite -/// tokens), not access grants. iroh admission via NIP-98 → relay membership is -/// the only gate; possession of these fields confers no ability to dial a -/// serving node that has not admitted the caller's pubkey. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BuzzMeshStatus { - /// Schema version. - pub v: u32, - /// Constant discriminator: `buzz-mesh-status`. - #[serde(rename = "type")] - pub status_type: String, - /// Unix timestamp when the relay generated this projection. - pub updated_at: u64, - /// Mesh identity, if mesh-llm has joined/created one. - #[serde(skip_serializing_if = "Option::is_none")] - pub mesh_id: Option, - /// Human mesh name, if configured. - #[serde(skip_serializing_if = "Option::is_none")] - pub mesh_name: Option, - /// Serving targets reachable by relay members. - pub serve_targets: Vec, - /// Deduplicated model options for agent/provider pickers. - pub models: Vec, - /// Aggregate peer count from mesh status. - pub peer_count: usize, -} - -/// Model value + display label. `id` is the API/routing value; `name` is UI-only. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MeshModelOption { - /// Stable routable OpenAI model id/ref. This is the value clients pass to `/v1`. - pub id: String, - /// Optional UI label. Must never replace [`Self::id`] for routing. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, -} - -/// A model served at an EndpointAddr dial pointer. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MeshServeTarget { - /// Stable routable OpenAI model id/ref served at this target. - pub model_id: String, - /// Optional UI label for [`Self::model_id`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub model_name: Option, - /// mesh-llm invite token: base64(json(EndpointAddr)). This is dial - /// metadata, not an access grant; iroh admission remains the gate. - pub endpoint_addr: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional node label/hostname for UI display. - pub node_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional serving capacity hint for this target. - pub capacity: Option, -} - -/// Capacity hint for a serving target. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MeshTargetCapacity { - /// Advertised VRAM capacity in GB, if known. - pub vram_gb: Option, -} - -/// Build the relay-owned status projection from mesh's `/api/status` JSON. -pub fn sanitize_mesh_status(payload: &Value, now_unix: u64) -> BuzzMeshStatus { - let endpoint_addr = string_field(payload, "token").unwrap_or_default(); - let node_id = string_field(payload, "node_id"); - let mesh_id = string_field(payload, "mesh_id"); - let mesh_name = string_field(payload, "mesh_name"); - let my_vram_gb = payload.get("my_vram_gb").and_then(Value::as_f64); - - let mut models = Vec::::new(); - let mut serve_targets = Vec::::new(); - - for model_id in string_array_field(payload, "hosted_models") - .into_iter() - .chain(string_array_field(payload, "serving_models")) - { - push_model(&mut models, &model_id, None); - if !endpoint_addr.is_empty() { - push_target( - &mut serve_targets, - MeshServeTarget { - model_id, - model_name: None, - endpoint_addr: endpoint_addr.clone(), - node_name: node_id.clone(), - capacity: Some(MeshTargetCapacity { - vram_gb: my_vram_gb, - }), - }, - ); - } - } - - let peers = payload - .get("peers") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - for peer in &peers { - let peer_endpoint = string_field(peer, "invite_token") - .or_else(|| string_field(peer, "endpoint_addr")) - .or_else(|| string_field(peer, "endpointAddr")); - let peer_name = string_field(peer, "hostname").or_else(|| string_field(peer, "id")); - let peer_vram_gb = peer.get("vram_gb").and_then(Value::as_f64); - - for model_id in string_array_field(peer, "hosted_models") - .into_iter() - .chain(string_array_field(peer, "serving_models")) - { - push_model(&mut models, &model_id, None); - if let Some(endpoint_addr) = peer_endpoint.clone() { - push_target( - &mut serve_targets, - MeshServeTarget { - model_id, - model_name: None, - endpoint_addr, - node_name: peer_name.clone(), - capacity: Some(MeshTargetCapacity { - vram_gb: peer_vram_gb, - }), - }, - ); - } - } - } - - models.sort_by(|a, b| a.id.cmp(&b.id)); - serve_targets.sort_by(|a, b| { - a.model_id - .cmp(&b.model_id) - .then_with(|| a.endpoint_addr.cmp(&b.endpoint_addr)) - }); - - BuzzMeshStatus { - v: 1, - status_type: MESH_STATUS_TYPE.to_string(), - updated_at: now_unix, - mesh_id, - mesh_name, - serve_targets, - models, - peer_count: peers.len(), - } -} - -/// Publish sanitized mesh status as a relay-signed kind:30621 event, keyed to -/// the reporting member's pubkey so each member's note is isolated. -pub async fn publish_mesh_status_from_payload( - state: &Arc, - tenant: &TenantContext, - reporter_pubkey_hex: &str, - payload: &Value, -) -> anyhow::Result<()> { - let now_unix = chrono::Utc::now().timestamp().max(0) as u64; - let status = sanitize_mesh_status(payload, now_unix); - publish_mesh_status(state, tenant, reporter_pubkey_hex, &status).await -} - -/// Publish a pre-sanitized mesh status. Exposed for tests and integration seams. -pub async fn publish_mesh_status( - state: &Arc, - tenant: &TenantContext, - reporter_pubkey_hex: &str, - status: &BuzzMeshStatus, -) -> anyhow::Result<()> { - let content = serde_json::to_string(status)?; - let d_tag = mesh_status_d_tag(reporter_pubkey_hex); - let tags = vec![ - Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?, - Tag::parse(["d", &d_tag]).map_err(|e| anyhow::anyhow!("failed to build d tag: {e}"))?, - Tag::parse(["k", MESH_STATUS_TYPE]) - .map_err(|e| anyhow::anyhow!("failed to build k tag: {e}"))?, - ]; - - let event = EventBuilder::new(Kind::Custom(KIND_MESH_LLM_RELAY_STATUS as u16), content) - .tags(tags) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_MESH_LLM_RELAY_STATUS}: {e}"))?; - - let (stored, was_inserted) = state - .db - .replace_parameterized_event(tenant.community(), &event, &d_tag, None) - .await?; - if was_inserted { - let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); - dispatch_persistent_event( - tenant, - state, - &stored, - KIND_MESH_LLM_RELAY_STATUS, - &relay_pubkey_hex, - None, - ) - .await; - } - - info!( - targets = status.serve_targets.len(), - models = status.models.len(), - "mesh-LLM relay status published" - ); - Ok(()) -} - -fn string_field(value: &Value, key: &str) -> Option { - value - .get(key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) -} - -fn string_array_field(value: &Value, key: &str) -> Vec { - value - .get(key) - .and_then(Value::as_array) - .map(|values| { - values - .iter() - .filter_map(Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() -} - -fn push_model(models: &mut Vec, id: &str, name: Option) { - if models.iter().any(|model| model.id == id) { - return; - } - models.push(MeshModelOption { - id: id.to_string(), - name, - }); -} - -fn push_target(targets: &mut Vec, target: MeshServeTarget) { - if targets.iter().any(|existing| { - existing.model_id == target.model_id && existing.endpoint_addr == target.endpoint_addr - }) { - return; - } - targets.push(target); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn d_tag_is_per_reporter_so_members_never_clobber() { - let a = "aa".repeat(32); - let b = "bb".repeat(32); - let d_a = mesh_status_d_tag(&a); - let d_b = mesh_status_d_tag(&b); - // Two different members → two different d-tags → isolated 30621 notes - // (NIP-33 keys on (kind, pubkey, d); the relay is always the author, so - // the d-tag is the only thing distinguishing reporters). - assert_ne!(d_a, d_b, "distinct reporters must get distinct d-tags"); - assert!(d_a.starts_with(MESH_STATUS_D_TAG_PREFIX)); - assert!(d_a.ends_with(&a)); - // Same reporter → same d-tag → its later report replaces its own note. - assert_eq!(d_a, mesh_status_d_tag(&a)); - } - - #[test] - fn sanitizer_projects_models_and_endpoint_addr_without_raw_runtime() { - let payload = serde_json::json!({ - "token": "endpoint-token-a", - "node_id": "node-a", - "mesh_id": "mesh-1", - "mesh_name": "buzz", - "hosted_models": ["Qwen3-8B-Q4_K_M"], - "serving_models": ["Qwen3-8B-Q4_K_M"], - "my_vram_gb": 12.0, - "runtime": { "stages": [{ "source_model_path": "/secret/model.gguf" }] }, - "local_instances": [{ "runtime_dir": "/tmp/private" }], - "peers": [{ - "id": "peer-b", - "hostname": "gpu-b", - "invite_token": "endpoint-token-b", - "hosted_models": ["TinyLlama"], - "vram_gb": 8.0 - }] - }); - - let status = sanitize_mesh_status(&payload, 123); - assert_eq!(status.status_type, MESH_STATUS_TYPE); - assert_eq!(status.mesh_id.as_deref(), Some("mesh-1")); - assert_eq!(status.models.len(), 2); - assert!(status.models.iter().any(|m| m.id == "Qwen3-8B-Q4_K_M")); - assert!(status.models.iter().any(|m| m.id == "TinyLlama")); - assert_eq!(status.serve_targets.len(), 2); - assert!(status - .serve_targets - .iter() - .any(|target| target.model_id == "Qwen3-8B-Q4_K_M" - && target.endpoint_addr == "endpoint-token-a")); - assert!(status - .serve_targets - .iter() - .any(|target| target.model_id == "TinyLlama" - && target.endpoint_addr == "endpoint-token-b")); - - let serialized = serde_json::to_string(&status).unwrap(); - assert!(!serialized.contains("source_model_path")); - assert!(!serialized.contains("runtime_dir")); - assert!(!serialized.contains("/secret")); - } - - #[test] - fn sanitizer_keeps_model_id_separate_from_label() { - let payload = serde_json::json!({ - "token": "endpoint-token-a", - "hosted_models": ["hf://meshllm/demo@main"], - "peers": [] - }); - - let status = sanitize_mesh_status(&payload, 123); - assert_eq!(status.models[0].id, "hf://meshllm/demo@main"); - assert_eq!(status.models[0].name, None); - assert_eq!(status.serve_targets[0].model_id, "hf://meshllm/demo@main"); - assert_eq!(status.serve_targets[0].model_name, None); - } -} diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 954e78434d..5c0b40b49d 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -14,7 +14,7 @@ //! recorded by [`track_metrics`] middleware on the app router. Buzz-specific //! metrics are recorded inline at their call sites. -use std::time::Instant; +use std::time::{Duration, Instant}; use axum::{ extract::{MatchedPath, Request}, @@ -22,6 +22,7 @@ use axum::{ response::Response, }; use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; +use metrics_util::MetricKindMask; /// HTTP latency buckets (milliseconds) — only for `http_request_latency_ms`. const LATENCY_BUCKETS_MS: [f64; 11] = [ @@ -41,9 +42,14 @@ const FANOUT_BUCKETS: [f64; 9] = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0, /// /// Must be called from within a Tokio runtime. /// Panics if a recorder is already installed or the port is in use. -pub fn install(port: u16) { +pub fn install(port: u16, gauge_idle_timeout_secs: u64) { let (recorder, exporter) = PrometheusBuilder::new() .with_http_listener(([0, 0, 0, 0], port)) + // Remove gauge series that the relay intentionally stops emitting. + .idle_timeout( + MetricKindMask::GAUGE, + Some(Duration::from_secs(gauge_idle_timeout_secs)), + ) // Per-metric buckets: ms for HTTP latency, seconds for internal processing. .set_buckets_for_metric( Matcher::Full("http_request_latency_ms".to_owned()), diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 03e0065efb..a8e397dd21 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -12,7 +12,7 @@ use crate::config::DEFAULT_MAX_FRAME_BYTES; /// /// NIP-43 (relay membership) is advertised separately by [`RelayInfo::build`] /// only when membership enforcement is actually enabled — see that function. -pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50]; +pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50, 56]; /// NIP-43 (relay membership). Advertised only when the relay actually /// enforces membership (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`) AND has a @@ -40,12 +40,18 @@ pub struct RelayInfo { /// Draft/extension protocol identifiers supported by this relay. #[serde(skip_serializing_if = "Option::is_none")] pub supported_extensions: Option>, + /// NIP-PL executor descriptor. Present only when push delivery is configured. + #[serde(skip_serializing_if = "Option::is_none")] + pub push: Option, /// URL of the relay software repository. pub software: String, /// Relay software version string. pub version: String, /// Protocol and resource limits advertised to clients. pub limitation: Option, + /// Public WebSocket URL of the dedicated NIP-AB device-pairing relay. + #[serde(skip_serializing_if = "Option::is_none")] + pub pairing_relay_url: Option, /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, @@ -132,6 +138,7 @@ impl RelayInfo { icon: Option<&str>, advertise_nip43: bool, max_message_length: usize, + pairing_relay_url: Option<&str>, ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), @@ -151,9 +158,11 @@ impl RelayInfo { contact: None, supported_nips, supported_extensions: Some(vec!["nip-er".to_string()]), + push: None, software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), limitation: Some(relay_limitation(max_message_length)), + pairing_relay_url: pairing_relay_url.map(str::to_string), relay_self: relay_self.map(|s| s.to_string()), } } @@ -171,6 +180,52 @@ pub async fn relay_info_handler( axum::response::Json(nip11_document(&state, raw_host).await) } +fn push_descriptor( + push_configured: bool, + relay_url: &str, + executor_key_id: &str, + relay_keypair: &nostr::Keys, + tenant_host: Option<&str>, +) -> Option { + let host = tenant_host?; + push_configured.then_some(())?; + let scheme = if relay_url.starts_with("wss://") { + "wss" + } else { + "ws" + }; + Some(serde_json::json!({ + "origin": format!("{scheme}://{host}"), + "keys": [{ + "id": executor_key_id, + "pubkey": relay_keypair.public_key().to_hex(), + "current": true + }], + "app_profiles": [ + {"id": "buzz-ios-production", "transport": "apns"}, + {"id": "buzz-ios-sandbox", "transport": "apns"} + ], + "push_kinds": crate::handlers::push_lease::PUSH_KINDS, + "urgent_kinds": crate::handlers::push_lease::URGENT_KINDS, + "h_grammar": "uuid-v4-lowercase", + "class_support": {"apns": ["silent", "default", "time_sensitive"]}, + "limitation": { + "max_lease_ttl": 2592000, + "max_leases_per_pubkey": 16, + "max_subscriptions_per_lease": 16, + "max_kinds": 16, + "max_authors": 20, + "max_h": 50, + "max_tag_values": 20, + "max_ignore": 8, + "max_content_len": 65536, + "max_plaintext_len": 32768, + "max_endpoint_len": 4096, + "max_string_len": 512 + } + })) +} + /// Builds the served NIP-11 document for a request arriving on `raw_host`. /// /// Centralised so the content-negotiated root handler and the dedicated @@ -180,12 +235,34 @@ pub async fn relay_info_handler( pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo { let (relay_self, advertise_nip43) = nip11_facts(state); let icon = workspace_icon_for_host(state, raw_host).await; - RelayInfo::build( + let mut info = RelayInfo::build( relay_self.as_deref(), icon.as_deref(), advertise_nip43, state.config.max_frame_bytes, - ) + state.config.pairing_relay_url.as_deref(), + ); + let tenant_host = if state.config.push_gateway_delivery_url.is_some() { + crate::tenant::bind_community(&state.db, raw_host) + .await + .ok() + .map(|tenant| tenant.host().to_owned()) + } else { + None + }; + if let Some(push) = push_descriptor( + state.config.push_gateway_delivery_url.is_some(), + &state.config.relay_url, + &state.config.push_executor_key_id, + &state.relay_keypair, + tenant_host.as_deref(), + ) { + info.supported_extensions + .get_or_insert_default() + .push("nip-pl".to_string()); + info.push = Some(push); + } + info } /// Fetches the workspace icon for the community bound to `raw_host`, as the @@ -248,17 +325,35 @@ pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option, bo /// hard build break, the same way a deny-lint would. If you must change this /// signature, you are changing the conformance contract: update the conformance /// doc and prove the new input is host-scoped, not unscoped, first. +#[allow(clippy::type_complexity)] const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( Option<&str>, Option<&str>, bool, usize, + Option<&str>, ) -> RelayInfo = RelayInfo::build; #[cfg(test)] mod tests { use super::*; + #[test] + fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() { + let keys = nostr::Keys::generate(); + assert!( + push_descriptor(false, "ws://relay", "key", &keys, Some("tenant.example")).is_none() + ); + assert!(push_descriptor(true, "ws://relay", "key", &keys, None).is_none()); + let descriptor = push_descriptor(true, "ws://relay", "key", &keys, Some("tenant.example")) + .expect("configured push descriptor"); + assert_eq!(descriptor["origin"], "ws://tenant.example"); + assert_eq!( + descriptor["push_kinds"], + serde_json::json!(crate::handlers::push_lease::PUSH_KINDS) + ); + } + #[test] fn supported_nips_includes_nip23_and_nip33() { // Tests the production SUPPORTED_NIPS constant directly — no Config::from_env() @@ -281,12 +376,41 @@ mod tests { ); } + #[test] + fn supported_nips_includes_nip56() { + assert!( + SUPPORTED_NIPS.contains(&56), + "NIP-56 (reporting) must be advertised — kind:1984 ingest is live" + ); + } + #[test] fn build_advertises_buzz_repository_url() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); assert_eq!(info.software, "https://github.com/block/buzz"); } + #[test] + fn configured_pairing_relay_is_advertised_and_unset_value_is_omitted() { + let info = RelayInfo::build( + None, + None, + false, + DEFAULT_MAX_FRAME_BYTES, + Some("wss://pairing.buzz.xyz"), + ); + let json = serde_json::to_value(&info).expect("serialize"); + assert_eq!( + json.get("pairing_relay_url") + .and_then(|value| value.as_str()), + Some("wss://pairing.buzz.xyz") + ); + + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let json = serde_json::to_value(&info).expect("serialize"); + assert!(json.get("pairing_relay_url").is_none()); + } + /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard /// `icon` field; no icon (or a cleared, empty icon) omits the field /// entirely so the JSON matches pre-icon documents byte-for-byte. @@ -297,6 +421,7 @@ mod tests { Some("data:image/webp;base64,UklGRg=="), false, DEFAULT_MAX_FRAME_BYTES, + None, ); assert_eq!( info.icon.as_deref(), @@ -309,7 +434,7 @@ mod tests { ); for icon in [None, Some("")] { - let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES); + let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None); assert!(info.icon.is_none()); let json = serde_json::to_value(&info).expect("serialize"); assert!( @@ -329,7 +454,7 @@ mod tests { #[test] fn max_message_length_uses_configured_frame_limit() { - let info = RelayInfo::build(None, None, false, 262_144); + let info = RelayInfo::build(None, None, false, 262_144, None); let limitation = info.limitation.expect("limitation"); assert_eq!(limitation.max_message_length, Some(262_144)); } @@ -360,7 +485,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -373,7 +498,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES); + let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -382,7 +507,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES); + let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -393,6 +518,6 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES); + let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None); } } diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs new file mode 100644 index 0000000000..1c5b9c26fa --- /dev/null +++ b/crates/buzz-relay/src/push_runtime.rs @@ -0,0 +1,505 @@ +//! Durable NIP-PL event matcher and gateway delivery worker. + +use std::{sync::Arc, time::Duration}; + +use base64::Engine as _; +use buzz_core::filter::{filters_match, reader_authorized_for_event}; +use chrono::{TimeDelta, Utc}; +use nostr::{EventBuilder, Filter, Kind, Tag}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use tracing::{error, warn}; + +use crate::{handlers::push_lease::Subscription, state::AppState}; + +const CLAIM_SECS: i64 = 30; +const EVENT_USEFUL_SECS: i64 = 3600; +const MAX_ATTEMPTS: i32 = 8; + +#[derive(Serialize)] +struct DeliveryRequest<'a> { + v: u8, + endpoint_grant: &'a str, + request_id: uuid::Uuid, + expires_at: i64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case", tag = "status")] +enum DeliveryResponse { + Accepted, + InvalidEndpoint { + generation: i64, + invalid_at: Option, + }, + Retry { + retry_after_seconds: Option, + }, +} + +/// Continuously claim accepted events and match them against active leases. +pub async fn run_matcher(state: Arc) { + loop { + let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS); + match state.db.claim_due_push_match(until).await { + Ok(Some(job)) => { + if let Err(e) = process_match(&state, &job).await { + warn!(event_id=%job.event.event.id, attempt=job.attempt, "push match failed: {e}"); + if job.attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS { + // A poison event/lease must not retry forever or pin + // delivered outbox retention through the rematch guard. + let _ = state.db.complete_push_match(&job).await; + } else { + let _ = state + .db + .retry_push_match(&job, Utc::now() + TimeDelta::seconds(2)) + .await; + } + } else if let Err(e) = state.db.complete_push_match(&job).await { + warn!(event_id=%job.event.event.id, "push match completion failed: {e}"); + } + } + Ok(None) => tokio::time::sleep(Duration::from_millis(250)).await, + Err(e) => { + error!("push matcher claim failed: {e}"); + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + } +} + +async fn process_match(state: &AppState, job: &buzz_db::push::ClaimedMatch) -> anyhow::Result<()> { + let leases = state.db.active_push_match_leases(job.community).await?; + for lease in leases { + let author_hex = hex::encode(&lease.author); + if !reader_authorized_for_event(&job.event.event, &author_hex) { + continue; + } + if let Some(channel) = job.event.channel_id { + if !state + .db + .is_member(job.community, channel, &lease.author) + .await? + { + continue; + } + } + let subscriptions: Vec = serde_json::from_value(lease.subscriptions.clone())?; + let mut class: Option<&str> = None; + for sub in &subscriptions { + let filter: Filter = + serde_json::from_value(serde_json::Value::Object(sub.filter.clone()))?; + if !push_filter_authorized_for_event(&filter, &job.event.event, &author_hex) + || !filters_match(std::slice::from_ref(&filter), &job.event) + { + continue; + } + let ignored = sub.ignore.iter().any(|raw| { + serde_json::from_value::(serde_json::Value::Object(raw.clone())) + .is_ok_and(|f| filters_match(&[f], &job.event)) + }); + let p_count = job + .event + .event + .tags + .iter() + .filter(|t| t.kind().to_string() == "p") + .count() as u64; + if ignored + || sub + .suppress + .as_ref() + .is_some_and(|s| p_count > s.p_tags_max) + { + continue; + } + if class.is_none_or(|old| class_rank(&sub.class) > class_rank(old)) { + class = Some(&sub.class); + } + } + let Some(class) = class else { continue }; + let event_deadline = job.event.event.created_at.as_secs() as i64 + EVENT_USEFUL_SECS; + let expires_at = lease.expires_at.min(event_deadline); + if expires_at <= Utc::now().timestamp() { + continue; + } + let _ = state + .db + .enqueue_push_wake( + job.community, + &lease.author, + &lease.installation_id, + buzz_db::push::NewWake { + lease_generation: lease.generation, + event_id: job.event.event.id.as_bytes(), + class, + expires_at, + }, + ) + .await?; + } + Ok(()) +} + +/// Match-time counterpart of REQ's filter-level `#p` authorization gate. +/// Kind 1059 is globally stored and leaks recipient activity through wake +/// timing, so a lease may only match gift wraps addressed to its own author. +fn push_filter_authorized_for_event( + filter: &Filter, + event: &nostr::Event, + lease_author_hex: &str, +) -> bool { + if buzz_core::kind::event_kind_u32(event) != buzz_core::kind::KIND_GIFT_WRAP { + return true; + } + let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); + filter.generic_tags.get(&p).is_some_and(|values| { + !values.is_empty() + && values.iter().all(|value| value == lease_author_hex) + && event + .tags + .filter(nostr::TagKind::SingleLetter(p)) + .any(|tag| tag.content() == Some(lease_author_hex)) + }) +} + +/// Continuously claim due wakes and deliver them through the push gateway. +pub async fn run_delivery_worker(state: Arc) { + let http = reqwest::Client::builder() + .timeout(state.config.push_gateway_timeout) + .build() + .expect("push HTTP client"); + loop { + let mut found = false; + match state.db.usage_community_hosts().await { + Ok(communities) => { + for community in communities { + let community = buzz_core::CommunityId::from_uuid(community.id); + let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS); + match state.db.claim_due_push_wakes(community, 16, until).await { + Ok(wakes) => { + for wake in wakes { + found = true; + deliver_one(&state, &http, wake).await; + } + } + Err(e) => warn!(%community, "push wake claim failed: {e}"), + } + } + } + Err(e) => error!("push worker community scan failed: {e}"), + } + if !found { + tokio::time::sleep(Duration::from_millis(500)).await; + } + } +} + +async fn deliver_one( + state: &AppState, + http: &reqwest::Client, + claimed: buzz_db::push::ClaimedWake, +) { + let outcome = match state + .db + .revalidate_push_wake(claimed.community, claimed.id, claimed.claim_id) + .await + { + Ok(buzz_db::push::RevalidateWakeOutcome::Deliver(wake)) => wake, + Ok(buzz_db::push::RevalidateWakeOutcome::Suppressed) => { + let _ = state + .db + .fail_push_wake(claimed.community, claimed.id, claimed.claim_id) + .await; + return; + } + Err(e) => { + warn!(wake=%claimed.id, "push revalidation failed: {e}"); + return; + } + }; + if let Some(channel) = outcome.channel_id { + match state + .db + .is_member(outcome.community, channel, &outcome.author) + .await + { + Ok(true) => {} + Ok(false) => { + let _ = state + .db + .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) + .await; + return; + } + Err(e) => { + warn!(wake=%outcome.id, "push membership revalidation failed: {e}"); + let _ = state + .db + .retry_push_wake( + outcome.community, + outcome.id, + outcome.claim_id, + Utc::now() + TimeDelta::seconds(2), + ) + .await; + return; + } + } + } + // Membership I/O above can race lease replacement. Re-run the generation + // fence as the final database operation before transport. + let outcome = match state + .db + .revalidate_push_wake(outcome.community, outcome.id, outcome.claim_id) + .await + { + Ok(buzz_db::push::RevalidateWakeOutcome::Deliver(wake)) => wake, + Ok(buzz_db::push::RevalidateWakeOutcome::Suppressed) => { + let _ = state + .db + .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) + .await; + return; + } + Err(e) => { + warn!(wake=%outcome.id, "final push revalidation failed: {e}"); + return; + } + }; + let Some(url) = state.config.push_gateway_delivery_url.as_ref() else { + return; + }; + let body = delivery_body(&outcome.endpoint_grant, outcome.id, outcome.expires_at); + let auth = match nip98_header(&state.relay_keypair, url.as_str(), &body) { + Ok(auth) => auth, + Err(e) => { + warn!(wake=%outcome.id, "push auth failed: {e}"); + return; + } + }; + let response = send_gateway_request(http, url, body, auth).await; + match response { + Ok(r) if r.status().is_success() => match r.json::().await { + Ok(DeliveryResponse::Accepted) => { + let _ = state + .db + .complete_push_wake(outcome.community, outcome.id, outcome.claim_id) + .await; + } + _ => { + let _ = state + .db + .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) + .await; + } + }, + Ok(r) if r.status() == reqwest::StatusCode::GONE => { + match r.json::().await { + Ok(DeliveryResponse::InvalidEndpoint { + generation, + invalid_at, + }) => { + if generation == outcome.lease_generation { + let _ = state + .db + .disable_push_endpoint( + outcome.community, + &outcome.author, + &outcome.installation_id, + generation, + ) + .await; + } + warn!(wake=%outcome.id, ?invalid_at, "push endpoint permanently invalid"); + } + _ => warn!(wake=%outcome.id, "invalid closed-protocol 410 response"), + } + let _ = state + .db + .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) + .await; + } + Ok(r) if r.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE => { + let delay = match r.json::().await { + Ok(DeliveryResponse::Retry { + retry_after_seconds, + }) => retry_after_seconds + .filter(|seconds| *seconds > 0) + .unwrap_or(2), + _ => 2, + }; + retry_or_fail(state, &outcome, delay).await; + } + Ok(r) if r.status() == reqwest::StatusCode::TOO_MANY_REQUESTS => { + retry_or_fail(state, &outcome, 2).await + } + // A timed-out terminal attempt burns the stable request id. Its replay + // is indistinguishable from another invalid-grant 404, but sending a + // fresh id would double-deliver and defeat the gateway replay fence. + Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND && outcome.attempt > 1 => { + let _ = state + .db + .complete_push_wake(outcome.community, outcome.id, outcome.claim_id) + .await; + } + Err(e) if e.is_timeout() || e.is_connect() => retry_or_fail(state, &outcome, 2).await, + _ => { + let _ = state + .db + .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) + .await; + } + } +} + +fn delivery_body(endpoint_grant: &str, request_id: uuid::Uuid, expires_at: i64) -> Vec { + serde_json::to_vec(&DeliveryRequest { + v: 1, + endpoint_grant, + request_id, + expires_at, + }) + .expect("closed delivery body") +} + +async fn send_gateway_request( + http: &reqwest::Client, + url: &url::Url, + body: Vec, + auth: String, +) -> reqwest::Result { + http.post(url.clone()) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body) + .send() + .await +} + +async fn retry_or_fail(state: &AppState, wake: &buzz_db::push::ClaimedWake, delay: i64) { + if wake.attempt >= MAX_ATTEMPTS { + let _ = state + .db + .fail_push_wake(wake.community, wake.id, wake.claim_id) + .await; + } else { + let secs = delay * (1_i64 << (wake.attempt - 1).clamp(0, 6)); + let _ = state + .db + .retry_push_wake( + wake.community, + wake.id, + wake.claim_id, + Utc::now() + TimeDelta::seconds(secs), + ) + .await; + } +} + +fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result { + let hash = hex::encode(Sha256::digest(body)); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", url])?, + Tag::parse(["method", "POST"])?, + Tag::parse(["payload", &hash])?, + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])?, + ]) + .sign_with_keys(keys)?; + Ok(format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event)?) + )) +} + +fn class_rank(class: &str) -> u8 { + match class { + "silent" => 0, + "default" => 1, + "time_sensitive" => 2, + "urgent" => 3, + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{extract::State, routing::post, Json, Router}; + use serde_json::Value; + use std::{future::IntoFuture, sync::Arc}; + use tokio::sync::Mutex; + + #[test] + fn gift_wrap_match_requires_self_p_filter_and_recipient() { + let recipient = nostr::Keys::generate(); + let other = nostr::Keys::generate(); + let sender = nostr::Keys::generate(); + let recipient_hex = recipient.public_key().to_hex(); + let event = EventBuilder::new(Kind::GiftWrap, "ciphertext") + .tag(Tag::public_key(other.public_key())) + .sign_with_keys(&sender) + .unwrap(); + let self_filter = Filter::new().pubkey(recipient.public_key()); + assert!(!push_filter_authorized_for_event( + &self_filter, + &event, + &recipient_hex + )); + + let event = EventBuilder::new(Kind::GiftWrap, "ciphertext") + .tag(Tag::public_key(recipient.public_key())) + .sign_with_keys(&sender) + .unwrap(); + assert!(push_filter_authorized_for_event( + &self_filter, + &event, + &recipient_hex + )); + assert!(!push_filter_authorized_for_event( + &Filter::new().author(sender.public_key()), + &event, + &recipient_hex + )); + } + + async fn capture( + State(seen): State>>>, + Json(body): Json, + ) -> Json { + seen.lock().await.push(body); + Json(serde_json::json!({"status":"accepted"})) + } + + #[tokio::test] + async fn gateway_retries_send_the_same_request_id_over_http() { + let seen = Arc::new(Mutex::new(Vec::new())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn( + axum::serve( + listener, + Router::new() + .route("/deliver", post(capture)) + .with_state(seen.clone()), + ) + .into_future(), + ); + let url: url::Url = format!("http://{address}/deliver").parse().unwrap(); + let http = reqwest::Client::new(); + let keys = nostr::Keys::generate(); + let request_id = uuid::Uuid::new_v4(); + for _ in 0..2 { + let body = delivery_body("opaque-grant", request_id, Utc::now().timestamp() + 60); + let auth = nip98_header(&keys, url.as_str(), &body).unwrap(); + let response = send_gateway_request(&http, &url, body, auth).await.unwrap(); + assert!(response.status().is_success()); + } + server.abort(); + let bodies = seen.lock().await; + assert_eq!(bodies.len(), 2); + assert_eq!(bodies[0]["request_id"], request_id.to_string()); + assert_eq!(bodies[1]["request_id"], request_id.to_string()); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index fc9e1ec38e..bb8de55569 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -12,6 +12,7 @@ use axum::{ Router, }; use serde_json::json; +use tower::ServiceExt; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::limit::RequestBodyLimitLayer; use tower_http::services::ServeDir; @@ -60,8 +61,41 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + .route( + "/operator/communities", + get(api::operator::list_owned_communities).post(api::operator::provision_community), + ) + .route( + "/operator/communities/archive", + post(api::operator::archive_community), + ) + .route( + "/operator/communities/unarchive", + post(api::operator::unarchive_community), + ) + .route( + "/operator/communities/availability", + get(api::operator::community_availability), + ) + .route( + "/operator/communities/transfer", + post(api::operator::transfer_community), + ) + // Relay invites: mint (owner/admin) + claim (membership-gate exempt) + .route("/api/invites", post(api::invites::mint_invite)) + .route("/api/invites/claim", post(api::invites::claim_invite)) + // Moderation queue reads (NIP-98 auth + mod-authz gate, L6) + .route("/moderation/reports", get(api::bridge::moderation_reports)) + .route("/moderation/audit", get(api::bridge::moderation_audit)) + .route( + "/moderation/restricted", + get(api::bridge::moderation_restricted), + ) // Webhook trigger (secret-authenticated, no NIP-98) .route("/hooks/{id}", post(api::bridge::workflow_webhook)) + // Mesh demo echo probe — testbed-only; 404 unless BUZZ_MESH=on and + // BUZZ_MESH_DEMO_ECHO=on (see api::mesh_demo). + .route("/_mesh/demo/echo", post(api::mesh_demo::demo_echo)) // Huddle audio WebSocket route .route( "/huddle/{channel_id}/audio", @@ -78,40 +112,31 @@ pub fn build_router(state: Arc) -> Router { .merge(git_router) .merge(git_policy_router); - // When BUZZ_WEB_DIR is set, serve the SPA as a fallback for unmatched routes. + // When BUZZ_WEB_DIR is set, serve either the full SPA or its invite-only + // surface. Invite-only mode deliberately exposes only /invite/{code} and + // hashed build assets; root and repository browser routes remain absent. if let Some(ref web_dir) = state.config.web_dir { let index_path = web_dir.join("index.html"); - let spa_fallback = ServeDir::new(web_dir).not_found_service(tower::service_fn( - move |req: axum::extract::Request| { - let index = index_path.clone(); - async move { - let path = req.uri().path(); - // Reserved API prefixes must 404 normally, not serve index.html. - let reserved = path.starts_with("/api/") - || path.starts_with("/media/") - || path.starts_with("/git/") - || path.starts_with("/internal/") - || path.starts_with("/.well-known/") - || path.starts_with("/huddle/") - || path == "/health" - || path == "/_liveness" - || path == "/_readiness" - || path == "/_status" - || path == "/info"; - // Files with extensions (e.g. /assets/missing.js) should 404. - let has_ext = path.rsplit('/').next().is_some_and(|seg| seg.contains('.')); - if reserved || has_ext { - Ok(StatusCode::NOT_FOUND.into_response()) - } else { - // SPA client-side route → serve index.html - match tokio::fs::read(&index).await { - Ok(body) => Ok(axum::response::Html(body).into_response()), - Err(_) => Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()), - } - } + let static_files = ServeDir::new(web_dir); + let serve_git_web_gui = state.config.serve_git_web_gui; + let spa_fallback = tower::service_fn(move |req: axum::extract::Request| { + let index = index_path.clone(); + let static_files = static_files.clone(); + async move { + let path = req.uri().path(); + if path.starts_with("/assets/") { + return static_files + .oneshot(req) + .await + .map(IntoResponse::into_response); } - }, - )); + + if should_serve_spa(path, serve_git_web_gui) { + return Ok(read_spa_index(&index).await); + } + Ok(StatusCode::NOT_FOUND.into_response()) + } + }); merged = merged.fallback_service(spa_fallback); } @@ -121,6 +146,26 @@ pub fn build_router(state: Arc) -> Router { .layer(build_cors_layer(&state.config.cors_origins)) } +fn is_invite_landing_path(path: &str) -> bool { + path.strip_prefix("/invite/") + .is_some_and(|code| !code.is_empty() && !code.contains('/')) +} + +fn should_serve_spa(path: &str, serve_git_web_gui: bool) -> bool { + is_invite_landing_path(path) || (serve_git_web_gui && is_git_web_gui_path(path)) +} + +fn is_git_web_gui_path(path: &str) -> bool { + path == "/" || path == "/repos" || path.starts_with("/repos/") +} + +async fn read_spa_index(index: &std::path::Path) -> axum::response::Response { + match tokio::fs::read(index).await { + Ok(body) => axum::response::Html(body).into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + /// Build the health-only router for K8s probes (port 8080 in CAKE). /// /// No metrics middleware, no auth, no CORS, no body limit. @@ -129,6 +174,7 @@ pub fn build_health_router(state: Arc) -> Router { .route("/_liveness", get(liveness_handler)) .route("/_readiness", get(readiness_handler)) .route("/_status", get(status_handler)) + .route("/_mesh", get(mesh_status_handler)) .with_state(state) } @@ -184,12 +230,14 @@ async fn nip11_or_ws_handler( .on_upgrade(move |socket| handle_connection(socket, state, addr, tenant)) .into_response(), Err(_) => { - // Browser requesting HTML and web UI is configured → serve SPA. - if let Some(ref dir) = state.config.web_dir { - if accept.contains("text/html") { - let index = dir.join("index.html"); - if let Ok(body) = tokio::fs::read(&index).await { - return axum::response::Html(body).into_response(); + // Browser requesting HTML and Git web GUI is enabled → serve SPA. + if state.config.serve_git_web_gui { + if let Some(ref dir) = state.config.web_dir { + if accept.contains("text/html") { + let index = dir.join("index.html"); + if let Ok(body) = tokio::fs::read(&index).await { + return axum::response::Html(body).into_response(); + } } } } @@ -251,6 +299,18 @@ async fn status_handler(State(state): State>) -> impl IntoResponse })) } +/// `/_mesh` — live mesh status: peer table, connection/phi state, per-peer +/// counters, fence-rejection totals. Mesh-off reports `{"enabled": false}` so +/// operators can distinguish "off" from "on with zero peers". +async fn mesh_status_handler(State(state): State>) -> impl IntoResponse { + match state.mesh() { + Some(handle) => Json(serde_json::to_value(handle.status()).unwrap_or_else( + |e| json!({"enabled": true, "error": format!("status serialize: {e}")}), + )), + None => Json(json!({"enabled": false})), + } +} + /// Build a CORS layer from the configured origins list. fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { if cors_origins.is_empty() { @@ -276,3 +336,38 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { .allow_methods(tower_http::cors::Any) .allow_headers(tower_http::cors::Any) } + +#[cfg(test)] +mod tests { + use super::{is_git_web_gui_path, is_invite_landing_path, should_serve_spa}; + + #[test] + fn invite_landing_path_requires_exactly_one_nonempty_code_segment() { + assert!(is_invite_landing_path("/invite/payload.mac")); + assert!(!is_invite_landing_path("/invite/")); + assert!(!is_invite_landing_path("/invite/code/extra")); + assert!(!is_invite_landing_path("/repos")); + assert!(!is_invite_landing_path("/")); + } + + #[test] + fn git_web_gui_paths_are_explicit() { + assert!(is_git_web_gui_path("/")); + assert!(is_git_web_gui_path("/repos")); + assert!(is_git_web_gui_path("/repos/example")); + assert!(!is_git_web_gui_path("/repository")); + assert!(!is_git_web_gui_path("/arbitrary")); + assert!(!is_git_web_gui_path("/api/invites")); + } + + #[test] + fn invite_is_always_served_but_git_gui_requires_opt_in() { + assert!(should_serve_spa("/invite/payload.mac", false)); + assert!(should_serve_spa("/invite/payload.mac", true)); + assert!(!should_serve_spa("/", false)); + assert!(!should_serve_spa("/repos/example", false)); + assert!(should_serve_spa("/", true)); + assert!(should_serve_spa("/repos/example", true)); + assert!(!should_serve_spa("/arbitrary", true)); + } +} diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index a674e6d7c9..67888ec623 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1,6 +1,8 @@ //! Shared application state — Arc-wrapped, shared across all connections. -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; use std::sync::Arc; use std::time::Instant; @@ -20,6 +22,7 @@ use buzz_core::CommunityId; use buzz_db::Db; use buzz_media::MediaStorage; use buzz_pubsub::cache_invalidation::CacheInvalidation; +use buzz_pubsub::conn_control::ConnControl; use buzz_pubsub::{PubSubManager, RedisNip98ReplayGuard}; use buzz_search::SearchService; use buzz_workflow::WorkflowEngine; @@ -30,9 +33,17 @@ use crate::config::Config; use crate::connection::ConnectionSubscriptions; use crate::subscription::SubscriptionRegistry; +pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); +type SlidingWindowCounter = (u32, Instant); +type ScopedRateLimiter = DashMap; + /// Per-connection entry in the connection manager. struct ConnEntry { tx: mpsc::Sender, + /// Control-frame sender, drained ahead of data and before cancel wins in + /// the send loop. Used to deliver a ban-disconnect frame that must reach + /// the client before the socket is closed (see [`ConnectionManager::disconnect_pubkey`]). + ctrl_tx: mpsc::Sender, cancel: CancellationToken, /// Community resolved from the connection host at handshake. This is the /// receiver-side tenant label fan-out must compare against the event label. @@ -45,7 +56,128 @@ struct ConnEntry { grace_limit: u8, } -/// Tracks active WebSocket connections and provides message routing by connection ID. +/// Community-scoped lifecycle registry shared by every long-lived socket type. +/// +/// A handler registers before durable active-state revalidation. Archival after +/// registration cancels the token; archival before registration is observed by +/// the revalidation. The returned guard removes the entry on every exit path. +pub struct CommunityConnectionRegistry { + connections: Arc>, +} + +impl Default for CommunityConnectionRegistry { + fn default() -> Self { + Self::new() + } +} + +impl CommunityConnectionRegistry { + /// Creates an empty lifecycle registry. + pub fn new() -> Self { + Self { + connections: Arc::new(DashMap::new()), + } + } + + /// Registers one socket and returns a guard that deregisters it on drop. + pub fn register( + &self, + connection_id: Uuid, + community_id: CommunityId, + cancel: CancellationToken, + ) -> CommunityConnectionGuard { + self.connections + .insert(connection_id, (community_id, cancel)); + CommunityConnectionGuard { + connection_id, + connections: Arc::clone(&self.connections), + } + } + + /// Cancels every socket type currently bound to `community_id`. + pub fn disconnect_community(&self, community_id: CommunityId) -> usize { + let mut closed = 0; + for entry in self.connections.iter() { + if entry.value().0 == community_id { + entry.value().1.cancel(); + closed += 1; + } + } + closed + } + + /// Returns the distinct communities with live sockets on this pod. + pub fn bound_communities(&self) -> HashSet { + self.connections + .iter() + .map(|entry| entry.value().0) + .collect() + } +} + +/// Removes a socket lifecycle registration on every handler exit path. +pub struct CommunityConnectionGuard { + connection_id: Uuid, + connections: Arc>, +} + +impl Drop for CommunityConnectionGuard { + fn drop(&mut self) { + self.connections.remove(&self.connection_id); + } +} + +/// Registers a socket, durably revalidates its community, then runs it. +/// +/// The ordering is the archival admission invariant: archive-before-query is +/// observed by the query, while archive-after-registration sees the token. +pub async fn run_registered_community_connection( + registry: &CommunityConnectionRegistry, + connection_id: Uuid, + community_id: CommunityId, + cancel: CancellationToken, + check_active: Check, + run: Run, +) where + Check: FnOnce() -> CheckFuture, + CheckFuture: Future>, + Run: FnOnce() -> RunFuture, + RunFuture: Future, +{ + let _guard = registry.register(connection_id, community_id, cancel.clone()); + if !matches!(check_active().await, Ok(true)) { + cancel.cancel(); + return; + } + if cancel.is_cancelled() { + return; + } + run().await; + cancel.cancel(); +} + +async fn revalidate_registered_communities( + registry: &CommunityConnectionRegistry, + mut check_active: Check, +) -> (usize, Vec<(CommunityId, buzz_db::DbError)>) +where + Check: FnMut(CommunityId) -> CheckFuture, + CheckFuture: Future>, +{ + let communities = registry.bound_communities(); + let mut closed = 0; + let mut failures = Vec::new(); + for community_id in communities { + match check_active(community_id).await { + Ok(false) => closed += registry.disconnect_community(community_id), + Ok(true) => {} + Err(error) => failures.push((community_id, error)), + } + } + (closed, failures) +} + +/// Tracks active Nostr WebSocket connections and provides message routing by connection ID. pub struct ConnectionManager { connections: DashMap, } @@ -68,6 +200,7 @@ impl ConnectionManager { &self, conn_id: Uuid, tx: mpsc::Sender, + ctrl_tx: mpsc::Sender, cancel: CancellationToken, community_id: CommunityId, backpressure_count: Arc, @@ -78,6 +211,7 @@ impl ConnectionManager { conn_id, ConnEntry { tx, + ctrl_tx, cancel, community_id, backpressure_count, @@ -102,21 +236,31 @@ impl ConnectionManager { } } - /// Return all live connection IDs authenticated as `pubkey_bytes`. - pub fn connection_ids_for_pubkey(&self, pubkey_bytes: &[u8]) -> Vec { + /// Return live connection IDs authenticated as `pubkey_bytes` in one community. + /// + /// The same Nostr key may be connected to multiple communities at once. + /// Callers use this for tenant-visible cleanup such as presence clearing and + /// subscription eviction, so a connection in B must not keep A's derived + /// state alive. + pub fn connection_ids_for_pubkey_in_community( + &self, + community_id: CommunityId, + pubkey_bytes: &[u8], + ) -> Vec { self.connections .iter() .filter_map(|entry| { - let matches = entry - .authenticated_pubkey - .read() - .ok() - .and_then(|value| { - value - .as_ref() - .map(|stored| stored.as_slice() == pubkey_bytes) - }) - .unwrap_or(false); + let matches = entry.community_id == community_id + && entry + .authenticated_pubkey + .read() + .ok() + .and_then(|value| { + value + .as_ref() + .map(|stored| stored.as_slice() == pubkey_bytes) + }) + .unwrap_or(false); matches.then_some(*entry.key()) }) .collect() @@ -129,6 +273,50 @@ impl ConnectionManager { .and_then(|entry| entry.authenticated_pubkey.read().ok()?.clone()) } + /// Disconnect every live connection authenticated as `pubkey` **in + /// `community`**, delivering a final `OK false` frame carrying `reason` + /// before closing. + /// + /// Used for live ban enforcement (COMMUNITY_MODERATION_PLAN.md §0 decision + /// 4): a ban must take effect immediately on existing sessions, not just at + /// the next auth. The frame is sent on the control channel, which the send + /// loop drains ahead of both queued data and the biased cancel branch, so + /// the client learns *why* it was dropped. `event_id` labels the `OK` (the + /// ban has no triggering client event, so a synthetic all-zero id is used). + /// + /// The `community` filter is the tenant fence: one pod holds sockets for + /// many communities, and the same pubkey may be live in several. A ban in + /// community A must close only A's sockets, never a session the member holds + /// in community B ("authority stays inside the tenant fence"). + /// + /// Returns the number of connections closed. This is the pod-local half of + /// live enforcement; cross-pod fan-out publishes the same intent over Redis. + pub fn disconnect_pubkey( + &self, + community: CommunityId, + pubkey: &[u8], + event_id: &str, + reason: &str, + ) -> usize { + let frame = crate::protocol::RelayMessage::ok(event_id, false, reason); + let mut closed = 0usize; + for conn_id in self.connection_ids_for_pubkey_in_community(community, pubkey) { + if let Some(entry) = self.connections.get(&conn_id) { + if entry.community_id != community { + continue; + } + // Best-effort delivery: a full control buffer still gets the + // close via cancel below, just without the reason frame. + let _ = entry + .ctrl_tx + .try_send(WsMessage::Text(frame.clone().into())); + entry.cancel.cancel(); + closed += 1; + } + } + closed + } + /// Return the server-resolved community that the connection's host bound to. pub fn community_for_conn(&self, conn_id: Uuid) -> Option { self.connections @@ -143,6 +331,41 @@ impl ConnectionManager { .map(|entry| Arc::clone(&entry.subscriptions)) } + /// Snapshot the number of live WebSocket connections per community. + /// + /// Returns a map from community UUID to connection count. Used by the + /// usage poller; snapshotting avoids per-community gauge drift from + /// mismatched inc/dec across async boundaries. + pub fn per_community_ws_connections(&self) -> HashMap { + let mut counts: HashMap = HashMap::new(); + for entry in self.connections.iter() { + *counts.entry(entry.community_id).or_default() += 1; + } + counts + } + + /// Snapshot the number of distinct authenticated pubkeys online per community. + /// + /// A pubkey connected to multiple pods will be counted once per pod — the + /// dashboard sums across pods, so per-pod partial counts are correct. + /// A pubkey connected twice on the same pod is counted once (distinct set). + pub fn per_community_users_online(&self) -> HashMap { + // community_id → set of pubkey bytes + let mut seen: HashMap>> = HashMap::new(); + for entry in self.connections.iter() { + if let Ok(lock) = entry.authenticated_pubkey.read() { + if let Some(pk) = lock.as_ref() { + seen.entry(entry.community_id) + .or_default() + .insert(pk.clone()); + } + } + } + seen.into_iter() + .map(|(cid, set)| (cid, set.len() as u64)) + .collect() + } + /// Return the authenticated pubkey for a connection, if any. pub fn pubkey_for(&self, conn_id: Uuid) -> Option> { self.connections @@ -226,6 +449,12 @@ pub struct AppState { pub sub_registry: Arc, /// Registry of active WebSocket connections. pub conn_manager: Arc, + /// Lifecycle cancellation for every long-lived socket, including huddle audio. + pub community_connections: Arc, + /// Stops only the periodic lifecycle revalidator during graceful shutdown. + pub community_revalidator_cancel: CancellationToken, + /// Test/telemetry counter for archive disconnect publication attempts. + pub community_disconnect_publish_attempts: Arc, /// Semaphore limiting total concurrent connections. pub conn_semaphore: Arc, /// Semaphore limiting concurrent message handler tasks. @@ -291,20 +520,20 @@ pub struct AppState { pub nip98_replay: Arc, /// Per-agent sliding-window rate limiter for observer frames (kind 24200). - /// Key: agent pubkey bytes (32). Value: (count, window_start). + /// Key: (community_id, agent pubkey bytes). Value: (count, window_start). /// 100 events/sec per agent — prevents relay/DB pressure from bursty telemetry. - pub observer_rate_limiter: Arc>, - /// Per-requester sliding-window rate limiter for mesh connect requests - /// (kind 24621). Key: requester pubkey bytes (32). Value: (count, - /// window_start). Bounds the 1→2 call-me-now amplification: a member is - /// trusted, but a buggy desktop loop shouldn't make the relay sign+fan - /// unboundedly. 20/sec is far above any real interactive use. - pub mesh_connect_rate_limiter: Arc>, + pub observer_rate_limiter: Arc, /// Per-uploader sliding-window rate limiter for media upload starts. - /// Key: uploader pubkey bytes (32). Value: (count, window_start). - pub media_upload_rate_limiter: Arc>, - /// Current in-flight media uploads per uploader pubkey. - pub media_uploads_in_flight: Arc>, + /// Key: (community_id, uploader pubkey bytes). Value: (count, window_start). + pub media_upload_rate_limiter: Arc, + /// Per-claimer fixed-window rate limiter for invite claim attempts + /// (`POST /api/invites/claim`). Entries expire after the claim window and + /// the cache has a hard capacity because pre-membership callers can cheaply + /// generate fresh Nostr keys. + pub invite_claim_rate_limiter: + Arc>>, + /// Current in-flight media uploads per (community, uploader pubkey). + pub media_uploads_in_flight: Arc>, /// Cache for observer agent-owner authorization (kind 24200). /// Key: (agent_pubkey_bytes, owner_pubkey_bytes). Value: is_owner. /// agent_owner_pubkey is immutable so a long TTL (5 min) is safe. @@ -318,6 +547,13 @@ pub struct AppState { /// See `crates/buzz-conformance/` and `crate::conformance` for the /// schema, emitter helpers, and the independent checker. pub tracer: Arc, + + /// Inter-relay mesh handle, set once by `main.rs` after `mesh_boot` (never + /// a constructor parameter, so `AppState::new` call sites are untouched). + /// `None`/unset ⇒ mesh-off / single-instance: consumers must behave + /// byte-identically to a relay without the mesh. Access via + /// [`AppState::mesh`]. + pub mesh: Arc>, } impl AppState { @@ -400,6 +636,9 @@ impl AppState { search: search_arc, sub_registry: Arc::new(SubscriptionRegistry::new()), conn_manager: Arc::new(ConnectionManager::new()), + community_connections: Arc::new(CommunityConnectionRegistry::new()), + community_revalidator_cancel: CancellationToken::new(), + community_disconnect_publish_attempts: Arc::new(AtomicU64::new(0)), conn_semaphore: Arc::new(Semaphore::new(max_connections)), handler_semaphore: Arc::new(Semaphore::new(max_concurrent_handlers)), git_semaphore: Arc::new(Semaphore::new(git_max_concurrent_ops)), @@ -417,18 +656,21 @@ impl AppState { moka::sync::Cache::builder() .max_capacity(10_000) .time_to_live(std::time::Duration::from_secs(10)) + .support_invalidation_closures() .build(), ), accessible_channels_cache: Arc::new( moka::sync::Cache::builder() .max_capacity(10_000) .time_to_live(std::time::Duration::from_secs(10)) + .support_invalidation_closures() .build(), ), channel_visibility_cache: Arc::new( moka::sync::Cache::builder() .max_capacity(10_000) .time_to_live(std::time::Duration::from_secs(10)) + .support_invalidation_closures() .build(), ), audit_tx, @@ -439,8 +681,13 @@ impl AppState { started_at: Instant::now(), nip98_replay, observer_rate_limiter: Arc::new(DashMap::new()), - mesh_connect_rate_limiter: Arc::new(DashMap::new()), media_upload_rate_limiter: Arc::new(DashMap::new()), + invite_claim_rate_limiter: Arc::new( + moka::sync::Cache::builder() + .max_capacity(crate::api::invites::CLAIM_RATE_CACHE_CAPACITY) + .time_to_live(crate::api::invites::CLAIM_RATE_WINDOW) + .build(), + ), media_uploads_in_flight: Arc::new(DashMap::new()), observer_owner_cache: Arc::new( moka::sync::Cache::builder() @@ -453,6 +700,7 @@ impl AppState { // construction (see test helpers in // `crates/buzz-test-client` once those land). tracer: Arc::new(crate::conformance::NoopTracer), + mesh: Arc::new(std::sync::OnceLock::new()), }; ( state, @@ -463,6 +711,12 @@ impl AppState { ) } + /// Inter-relay mesh handle. `None` ⇒ mesh-off / single-instance: callers + /// must no-op to today's behavior. Set once by `main.rs` after boot. + pub fn mesh(&self) -> Option<&crate::mesh_boot::MeshHandle> { + self.mesh.get() + } + /// Record an event ID as locally-published for dedup, scoped to the /// community it was fanned out in. Called before Redis publish so the /// multi-node consumer can skip the echo for *this* community only — a @@ -524,13 +778,25 @@ impl AppState { /// Invalidate all users' accessible-channels cache (e.g. new open channel created). pub fn invalidate_all_accessible_channels(&self, tenant: &TenantContext) { - self.invalidate_all_accessible_channels_local(); + self.invalidate_all_accessible_channels_local(tenant.community()); self.spawn_cache_invalidation(tenant, CacheInvalidation::AccessibleAll); } /// Local-only accessible-channels drop. See [`invalidate_membership_local`]. - pub(crate) fn invalidate_all_accessible_channels_local(&self) { - self.accessible_channels_cache.invalidate_all(); + pub(crate) fn invalidate_all_accessible_channels_local(&self, community_id: CommunityId) { + if let Err(error) = self + .accessible_channels_cache + .invalidate_entries_if(move |(entry_community, _), _| *entry_community == community_id) + { + // AppState enables invalidation closures at construction time. If + // that invariant ever regresses, prefer over-invalidating to + // serving stale access state. + tracing::error!( + ?error, + "community-scoped accessible-channel invalidation unavailable; falling back to full invalidation" + ); + self.accessible_channels_cache.invalidate_all(); + } } /// Invalidate the cached visibility for a single channel (e.g. after a flip). @@ -551,20 +817,50 @@ impl AppState { /// Invalidate all caches after a channel is deleted. /// - /// Channel deletion is a rare admin operation. We clear the entire membership - /// cache because moka doesn't support prefix-based invalidation on composite - /// keys, and stale `is_member=true` entries for a deleted channel would bypass - /// the DB's `deleted_at IS NULL` guard. + /// Channel deletion is a rare admin operation, but it is still tenant-local: + /// a deletion in A must not flush B's cache entries. Predicate invalidation + /// keeps the safety property that stale `is_member=true` entries for the + /// deleted channel are removed without turning the cache drop into a + /// cross-community signal. pub fn invalidate_channel_deleted(&self, tenant: &TenantContext) { - self.invalidate_channel_deleted_local(); + self.invalidate_channel_deleted_local(tenant.community()); self.spawn_cache_invalidation(tenant, CacheInvalidation::ChannelDeleted); } /// Local-only channel-deleted drop. See [`invalidate_membership_local`]. - pub(crate) fn invalidate_channel_deleted_local(&self) { - self.membership_cache.invalidate_all(); - self.accessible_channels_cache.invalidate_all(); - self.channel_visibility_cache.invalidate_all(); + pub(crate) fn invalidate_channel_deleted_local(&self, community_id: CommunityId) { + if let Err(error) = + self.membership_cache + .invalidate_entries_if(move |(entry_community, _, _), _| { + *entry_community == community_id + }) + { + tracing::error!( + ?error, + "community-scoped membership invalidation unavailable; falling back to full invalidation" + ); + self.membership_cache.invalidate_all(); + } + if let Err(error) = self + .accessible_channels_cache + .invalidate_entries_if(move |(entry_community, _), _| *entry_community == community_id) + { + tracing::error!( + ?error, + "community-scoped accessible-channel invalidation unavailable; falling back to full invalidation" + ); + self.accessible_channels_cache.invalidate_all(); + } + if let Err(error) = self + .channel_visibility_cache + .invalidate_entries_if(move |(entry_community, _), _| *entry_community == community_id) + { + tracing::error!( + ?error, + "community-scoped visibility invalidation unavailable; falling back to full invalidation" + ); + self.channel_visibility_cache.invalidate_all(); + } } /// Fire-and-forget publish of a cache-key drop to all other pods. Failures @@ -595,17 +891,104 @@ impl AppState { self.invalidate_membership_local(community_id, channel_id, &pubkey); } CacheInvalidation::AccessibleAll => { - self.invalidate_all_accessible_channels_local(); + self.invalidate_all_accessible_channels_local(community_id); } CacheInvalidation::Visibility { channel_id } => { self.invalidate_channel_visibility_local(community_id, channel_id); } CacheInvalidation::ChannelDeleted => { - self.invalidate_channel_deleted_local(); + self.invalidate_channel_deleted_local(community_id); } } } + /// Enforce a live ban cluster-wide: close this pod's sockets for `pubkey` + /// now (fenced to `tenant`'s community) and fan the same disconnect out to + /// every other pod over the conn-control Redis channel. + /// + /// This is the single entry point for live ban enforcement (decision 4: + /// "a ban takes effect immediately, everywhere, including live sessions"). + /// Callers must not invoke the pod-local `conn_manager.disconnect_pubkey` + /// directly — doing so closes sockets only on the pod that processed the + /// ban and silently drops the cluster-wide half. Pairing both halves here + /// makes that mistake unrepresentable. + /// + /// Returns the number of sockets closed on *this* pod only — remote pods + /// close asynchronously and do not report back, so callers must not treat + /// the count as cluster-wide truth. The cross-pod publish is fire-and-forget + /// (mirrors [`Self::spawn_cache_invalidation`]): the DB ban row is the + /// durable backstop, so a dropped publish still refuses the banned member's + /// next auth and next write. + pub fn disconnect_pubkey_clusterwide( + &self, + tenant: &TenantContext, + pubkey: &[u8], + event_id: &str, + reason: &str, + ) -> usize { + let closed = + self.conn_manager + .disconnect_pubkey(tenant.community(), pubkey, event_id, reason); + + // The banning pod re-receives its own publish through the subscriber and + // no-ops (its local sockets are already closed above) — intentional; do + // not add origin-suppression, it buys nothing. + let pubsub = Arc::clone(&self.pubsub); + let tenant = tenant.clone(); + let command = ConnControl::DisconnectPubkey { + pubkey: pubkey.to_vec(), + event_id: event_id.to_string(), + reason: reason.to_string(), + }; + // This pre-existing ban path may remain fire-and-forget because the + // durable ban row rejects the member again at auth. Community archival + // is different: its API awaits publication and live sockets also have a + // periodic durable-state revalidation backstop below. + tokio::spawn(async move { + if let Err(e) = pubsub.publish_conn_control(&tenant, &command).await { + tracing::warn!("Failed to publish conn-control disconnect: {e}"); + } + }); + + closed + } + + /// Disconnect a community locally and publish the command to every relay pod. + /// + /// Publication is awaited so the archive API can distinguish durable state + /// from propagation completion and offer a retryable response on failure. + pub async fn disconnect_community_clusterwide( + &self, + tenant: &TenantContext, + ) -> Result { + let closed = self + .community_connections + .disconnect_community(tenant.community()); + self.community_disconnect_publish_attempts + .fetch_add(1, Ordering::Relaxed); + self.pubsub + .publish_conn_control(tenant, &ConnControl::DisconnectCommunity) + .await?; + Ok(closed) + } + + /// Revalidate all communities with live sockets and cancel inactive ones. + /// + /// This is the durable backstop for Redis pub/sub's lossy offline-subscriber + /// semantics: a pod that missed a successful publish eventually observes the + /// archived row directly. + pub async fn revalidate_live_communities(&self) -> usize { + let (closed, failures) = + revalidate_registered_communities(&self.community_connections, |community_id| { + self.db.is_community_active(community_id) + }) + .await; + for (community_id, error) in failures { + tracing::warn!(%community_id, %error, "community lifecycle revalidation failed; retaining its sockets until next tick"); + } + closed + } + /// Get accessible channel IDs with a 10-second cache. Falls back to DB on miss. pub async fn get_accessible_channel_ids_cached( &self, @@ -744,36 +1127,77 @@ mod tests { use tokio::sync::{Mutex, RwLock}; /// Helper: create a ConnectionManager with one registered connection. - /// Returns (manager, conn_id, receiver, cancel, shared_backpressure_count). + /// Returns (manager, conn_id, receiver, ctrl_receiver, cancel, + /// shared_backpressure_count). fn setup_conn( buffer_size: usize, ) -> ( ConnectionManager, Uuid, mpsc::Receiver, + mpsc::Receiver, CancellationToken, Arc, ) { let mgr = ConnectionManager::new(); let conn_id = Uuid::new_v4(); let (tx, rx) = mpsc::channel(buffer_size); + let (ctrl_tx, ctrl_rx) = mpsc::channel(buffer_size); let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); mgr.register( conn_id, tx, + ctrl_tx, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), Arc::new(Mutex::new(HashMap::new())), 3, ); - (mgr, conn_id, rx, cancel, bp) + (mgr, conn_id, rx, ctrl_rx, cancel, bp) + } + + async fn test_state() -> Arc { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) } #[test] fn send_to_resets_grace_counter_on_success() { - let (mgr, id, _rx, _cancel, bp) = setup_conn(16); + let (mgr, id, _rx, _ctrl_rx, _cancel, bp) = setup_conn(16); // Simulate prior backpressure. bp.store(2, Ordering::Relaxed); assert!(mgr.send_to(id, "hello".into())); @@ -787,7 +1211,7 @@ mod tests { #[test] fn send_to_increments_grace_counter_on_full() { // Buffer size 1 — fill it, then the next send is Full. - let (mgr, id, _rx, cancel, bp) = setup_conn(1); + let (mgr, id, _rx, _ctrl_rx, cancel, bp) = setup_conn(1); assert!(mgr.send_to(id, "fill".into())); // Buffer is now full. assert!(!mgr.send_to(id, "overflow-1".into())); @@ -807,7 +1231,7 @@ mod tests { #[test] fn send_to_cancels_after_grace_limit() { - let (mgr, id, _rx, cancel, _bp) = setup_conn(1); + let (mgr, id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(1); assert!(mgr.send_to(id, "fill".into())); // Exhaust grace: 3 consecutive Full events (matches grace_limit=3 from setup_conn). for _ in 0..3u8 { @@ -849,6 +1273,7 @@ mod tests { mgr.register( conn_id, tx, + conn.ctrl_tx.clone(), cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), @@ -881,28 +1306,51 @@ mod tests { } #[tokio::test] - async fn tracks_connections_by_authenticated_pubkey() { + async fn tracks_connections_by_authenticated_pubkey_within_community() { let mgr = ConnectionManager::new(); - let conn_id = Uuid::new_v4(); - let (tx, _rx) = mpsc::channel(1); - let cancel = CancellationToken::new(); - let bp = Arc::new(AtomicU8::new(0)); - let subscriptions = Arc::new(Mutex::new(HashMap::new())); + let community_a = buzz_core::tenant::CommunityId::from_uuid(Uuid::from_u128(0xAAAA)); + let community_b = buzz_core::tenant::CommunityId::from_uuid(Uuid::from_u128(0xBBBB)); + let conn_a = Uuid::new_v4(); + let conn_b = Uuid::new_v4(); + let (tx_a, _rx_a) = mpsc::channel(1); + let (ctrl_tx_a, _ctrl_rx_a) = mpsc::channel(1); + let (tx_b, _rx_b) = mpsc::channel(1); + let (ctrl_tx_b, _ctrl_rx_b) = mpsc::channel(1); mgr.register( - conn_id, - tx, - cancel, - buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), - bp, - Arc::clone(&subscriptions), + conn_a, + tx_a, + ctrl_tx_a, + CancellationToken::new(), + community_a, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + mgr.register( + conn_b, + tx_b, + ctrl_tx_b, + CancellationToken::new(), + community_b, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), 3, ); let pubkey = vec![7u8; 32]; - mgr.set_authenticated_pubkey(conn_id, pubkey.clone()); + mgr.set_authenticated_pubkey(conn_a, pubkey.clone()); + mgr.set_authenticated_pubkey(conn_b, pubkey.clone()); - assert_eq!(mgr.connection_ids_for_pubkey(&pubkey), vec![conn_id]); - assert!(mgr.subscriptions_for(conn_id).is_some()); + assert_eq!( + mgr.connection_ids_for_pubkey_in_community(community_a, &pubkey), + vec![conn_a] + ); + assert_eq!( + mgr.connection_ids_for_pubkey_in_community(community_b, &pubkey), + vec![conn_b] + ); + assert!(mgr.subscriptions_for(conn_a).is_some()); + assert!(mgr.subscriptions_for(conn_b).is_some()); } #[tokio::test] @@ -910,12 +1358,14 @@ mod tests { let mgr = ConnectionManager::new(); let conn_id = Uuid::new_v4(); let (tx, _rx) = mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); mgr.register( conn_id, tx, + ctrl_tx, cancel, buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), bp, @@ -929,4 +1379,320 @@ mod tests { assert_eq!(mgr.pubkey_for_conn(conn_id), Some(pubkey)); assert_eq!(mgr.pubkey_for_conn(Uuid::new_v4()), None); } + + #[tokio::test] + async fn accessible_channel_invalidation_is_scoped_to_community() { + let state = test_state().await; + let community_a = CommunityId::from_uuid(Uuid::from_u128(0xAAAA)); + let community_b = CommunityId::from_uuid(Uuid::from_u128(0xBBBB)); + let pubkey = vec![7u8; 32]; + let channels_a = vec![Uuid::from_u128(1)]; + let channels_b = vec![Uuid::from_u128(2)]; + + state + .accessible_channels_cache + .insert((community_a, pubkey.clone()), channels_a); + state + .accessible_channels_cache + .insert((community_b, pubkey.clone()), channels_b.clone()); + + state.invalidate_all_accessible_channels_local(community_a); + + assert_eq!( + state + .accessible_channels_cache + .get(&(community_a, pubkey.clone())), + None + ); + assert_eq!( + state + .accessible_channels_cache + .get(&(community_b, pubkey.clone())), + Some(channels_b), + "A's cache drop must not evict B's accessible-channel entry" + ); + } + + #[tokio::test] + async fn channel_deleted_invalidation_is_scoped_to_community() { + let state = test_state().await; + let community_a = CommunityId::from_uuid(Uuid::from_u128(0xAAAA)); + let community_b = CommunityId::from_uuid(Uuid::from_u128(0xBBBB)); + let channel_id = Uuid::from_u128(1); + let pubkey = vec![7u8; 32]; + + for community in [community_a, community_b] { + state + .membership_cache + .insert((community, channel_id, pubkey.clone()), true); + state + .accessible_channels_cache + .insert((community, pubkey.clone()), vec![channel_id]); + state + .channel_visibility_cache + .insert((community, channel_id), "private".to_string()); + } + + state.invalidate_channel_deleted_local(community_a); + + assert_eq!( + state + .membership_cache + .get(&(community_a, channel_id, pubkey.clone())), + None + ); + assert_eq!( + state + .accessible_channels_cache + .get(&(community_a, pubkey.clone())), + None + ); + assert_eq!( + state + .channel_visibility_cache + .get(&(community_a, channel_id)), + None + ); + assert_eq!( + state + .membership_cache + .get(&(community_b, channel_id, pubkey.clone())), + Some(true) + ); + assert_eq!( + state + .accessible_channels_cache + .get(&(community_b, pubkey.clone())), + Some(vec![channel_id]) + ); + assert_eq!( + state + .channel_visibility_cache + .get(&(community_b, channel_id)), + Some("private".to_string()), + "A's channel deletion must not evict B's cache entries" + ); + } + + #[test] + fn community_lifecycle_disconnect_covers_socket_types_and_preserves_tenant_fence() { + let registry = CommunityConnectionRegistry::new(); + let community_a = CommunityId::from_uuid(Uuid::from_u128(0xa)); + let community_b = CommunityId::from_uuid(Uuid::from_u128(0xb)); + let ordinary_a = CancellationToken::new(); + let audio_a = CancellationToken::new(); + let ordinary_b = CancellationToken::new(); + let _ordinary_a_guard = registry.register(Uuid::new_v4(), community_a, ordinary_a.clone()); + let _audio_a_guard = registry.register(Uuid::new_v4(), community_a, audio_a.clone()); + let _ordinary_b_guard = registry.register(Uuid::new_v4(), community_b, ordinary_b.clone()); + + assert_eq!(registry.disconnect_community(community_a), 2); + assert!(ordinary_a.is_cancelled()); + assert!(audio_a.is_cancelled()); + assert!(!ordinary_b.is_cancelled()); + } + + #[tokio::test] + async fn register_then_revalidate_closes_both_archive_race_orderings() { + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xa)); + + // Archive wins before durable revalidation: the check observes inactive + // and the socket body never starts. + let cancel_before = CancellationToken::new(); + let started_before = Arc::new(AtomicBool::new(false)); + let started_before_run = Arc::clone(&started_before); + run_registered_community_connection( + ®istry, + Uuid::new_v4(), + community, + cancel_before.clone(), + || async { Ok(false) }, + move || async move { started_before_run.store(true, Ordering::SeqCst) }, + ) + .await; + assert!(cancel_before.is_cancelled()); + assert!(!started_before.load(Ordering::SeqCst)); + + // Archive wins after registration but while revalidation is paused: its + // sweep sees the token, and even an active query result cannot start the + // socket body afterward. + let cancel_during = CancellationToken::new(); + let registered = Arc::new(tokio::sync::Notify::new()); + let resume = Arc::new(tokio::sync::Notify::new()); + let registered_check = Arc::clone(®istered); + let resume_check = Arc::clone(&resume); + let started_during = Arc::new(AtomicBool::new(false)); + let started_during_run = Arc::clone(&started_during); + let future = run_registered_community_connection( + ®istry, + Uuid::new_v4(), + community, + cancel_during.clone(), + move || async move { + registered_check.notify_one(); + resume_check.notified().await; + Ok(true) + }, + move || async move { started_during_run.store(true, Ordering::SeqCst) }, + ); + tokio::pin!(future); + tokio::select! { + _ = registered.notified() => {} + _ = &mut future => panic!("revalidation should be paused"), + } + assert_eq!(registry.disconnect_community(community), 1); + resume.notify_one(); + future.await; + assert!(cancel_during.is_cancelled()); + assert!(!started_during.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn revalidation_continues_after_one_community_lookup_failure() { + let registry = CommunityConnectionRegistry::new(); + let archived_a = CommunityId::from_uuid(Uuid::from_u128(0xa)); + let failed = CommunityId::from_uuid(Uuid::from_u128(0xb)); + let archived_c = CommunityId::from_uuid(Uuid::from_u128(0xc)); + let cancel_a = CancellationToken::new(); + let cancel_failed = CancellationToken::new(); + let cancel_c = CancellationToken::new(); + let _guard_a = registry.register(Uuid::new_v4(), archived_a, cancel_a.clone()); + let _guard_failed = registry.register(Uuid::new_v4(), failed, cancel_failed.clone()); + let _guard_c = registry.register(Uuid::new_v4(), archived_c, cancel_c.clone()); + + let (closed, failures) = + revalidate_registered_communities(®istry, |community| async move { + if community == failed { + Err(buzz_db::DbError::InvalidData( + "injected lookup failure".into(), + )) + } else { + Ok(false) + } + }) + .await; + + assert_eq!(closed, 2); + assert!(cancel_a.is_cancelled()); + assert!(!cancel_failed.is_cancelled()); + assert!(cancel_c.is_cancelled()); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].0, failed); + assert_eq!( + registry.bound_communities(), + HashSet::from([archived_a, failed, archived_c]) + ); + } + + #[test] + fn community_lifecycle_guard_deregisters_on_early_return() { + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xa)); + let cancel = CancellationToken::new(); + let guard = registry.register(Uuid::new_v4(), community, cancel.clone()); + assert_eq!(registry.bound_communities(), HashSet::from([community])); + + drop(guard); + + assert!(registry.bound_communities().is_empty()); + assert_eq!(registry.disconnect_community(community), 0); + assert!(!cancel.is_cancelled()); + } + + #[tokio::test] + async fn disconnect_pubkey_closes_matching_conns_with_reason() { + let (mgr, id, _rx, mut ctrl_rx, cancel, _bp) = setup_conn(8); + let pubkey = vec![3u8; 32]; + mgr.set_authenticated_pubkey(id, pubkey.clone()); + + // setup_conn registers the connection under the nil community. + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + let closed = mgr.disconnect_pubkey( + community, + &pubkey, + "0".repeat(64).as_str(), + "blocked: banned", + ); + + assert_eq!(closed, 1, "the one matching connection is closed"); + assert!( + cancel.is_cancelled(), + "connection is cancelled (socket close)" + ); + // The reason frame is queued on the control channel ahead of the close. + let frame = ctrl_rx.try_recv().expect("reason frame delivered"); + match frame { + WsMessage::Text(t) => { + assert!(t.as_str().contains("blocked: banned"), "carries the reason"); + assert!(t.as_str().contains("false"), "is an OK false frame"); + } + other => panic!("expected text frame, got {other:?}"), + } + } + + #[tokio::test] + async fn disconnect_pubkey_ignores_non_matching_conns() { + let (mgr, id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(8); + mgr.set_authenticated_pubkey(id, vec![1u8; 32]); + + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + let closed = mgr.disconnect_pubkey( + community, + &[2u8; 32], + "0".repeat(64).as_str(), + "blocked: banned", + ); + + assert_eq!(closed, 0, "no connection matches a different pubkey"); + assert!(!cancel.is_cancelled(), "unrelated connection stays live"); + } + + #[tokio::test] + async fn disconnect_pubkey_is_fenced_to_the_banning_community() { + // Same pubkey, two live sockets in two different communities on one pod. + // A ban in community A must close only A's socket, never B's — the + // tenant fence on live-disconnect fan-out (B1). + let mgr = ConnectionManager::new(); + let pubkey = vec![7u8; 32]; + + let community_a = buzz_core::tenant::CommunityId::from_uuid(Uuid::from_u128(0xa)); + let community_b = buzz_core::tenant::CommunityId::from_uuid(Uuid::from_u128(0xb)); + + let register = |community| { + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + cancel.clone(), + community, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + mgr.set_authenticated_pubkey(conn_id, pubkey.clone()); + cancel + }; + + let cancel_a = register(community_a); + let cancel_b = register(community_b); + + let closed = mgr.disconnect_pubkey( + community_a, + &pubkey, + "0".repeat(64).as_str(), + "blocked: banned", + ); + + assert_eq!(closed, 1, "only the community-A socket is closed"); + assert!(cancel_a.is_cancelled(), "community-A session is closed"); + assert!( + !cancel_b.is_cancelled(), + "community-B session stays live — ban does not cross the tenant fence" + ); + } } diff --git a/crates/buzz-relay/src/subscription.rs b/crates/buzz-relay/src/subscription.rs index 419cf503cb..68f0fea3c4 100644 --- a/crates/buzz-relay/src/subscription.rs +++ b/crates/buzz-relay/src/subscription.rs @@ -351,6 +351,21 @@ impl SubscriptionRegistry { self.subs.len() } + /// Snapshot the number of active subscriptions per community. + /// + /// Used by the usage poller to emit `buzz_community_subscriptions{community}`. + /// Snapshotting avoids gauge drift from mismatched inc/dec across communities. + pub fn per_community_subscriptions(&self) -> std::collections::HashMap { + let mut counts: std::collections::HashMap = + std::collections::HashMap::new(); + for conn_entry in self.subs.iter() { + for (_, community_id, _) in conn_entry.value().values() { + *counts.entry(*community_id).or_default() += 1; + } + } + counts + } + fn push_match( &self, conn_id: ConnId, @@ -1463,4 +1478,85 @@ mod tests { vec![(conn_b, "b".to_string())] ); } + + #[test] + fn per_community_subscriptions_snapshot_is_correctly_scoped() { + // Verify that per_community_subscriptions() returns the correct + // per-community counts and that removal keeps the snapshot accurate. + let registry = SubscriptionRegistry::new(); + let community_a = CommunityId::from_uuid(Uuid::from_u128(0xaaaa)); + let community_b = CommunityId::from_uuid(Uuid::from_u128(0xbbbb)); + let conn_a = Uuid::new_v4(); + let conn_b = Uuid::new_v4(); + let channel = Uuid::new_v4(); + + // 2 subs in community A, 1 sub in community B. + registry.register_scoped( + community_a, + conn_a, + "a1".to_string(), + vec![Filter::new().kind(Kind::TextNote)], + Some(channel), + ); + registry.register_scoped( + community_a, + conn_a, + "a2".to_string(), + vec![Filter::new().kind(Kind::Metadata)], + None, + ); + registry.register_scoped( + community_b, + conn_b, + "b1".to_string(), + vec![Filter::new().kind(Kind::TextNote)], + Some(channel), + ); + + let snap = registry.per_community_subscriptions(); + assert_eq!(snap.get(&community_a), Some(&2), "community A: 2 subs"); + assert_eq!(snap.get(&community_b), Some(&1), "community B: 1 sub"); + + // Remove one sub from community A — snapshot should reflect it. + registry.remove_subscription(conn_a, "a1"); + let snap2 = registry.per_community_subscriptions(); + assert_eq!( + snap2.get(&community_a), + Some(&1), + "community A: 1 sub after removal" + ); + assert_eq!(snap2.get(&community_b), Some(&1), "community B: unchanged"); + } + + #[test] + fn per_community_subscriptions_drops_to_zero_when_all_subs_removed() { + // Regression: when the last subscription for a community is removed, + // per_community_subscriptions() must return no entry (not stale nonzero). + // The usage poller emits one explicit zero for the previously observed + // label before allowing its idle timeout to remove the series. + let registry = SubscriptionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xcccc)); + let conn = Uuid::new_v4(); + + registry.register_scoped( + community, + conn, + "c1".to_string(), + vec![Filter::new().kind(Kind::TextNote)], + None, + ); + + // Verify nonzero before removal. + let snap1 = registry.per_community_subscriptions(); + assert_eq!(snap1.get(&community), Some(&1), "1 sub before removal"); + + // Remove the only sub — community should no longer appear in snapshot. + registry.remove_subscription(conn, "c1"); + let snap2 = registry.per_community_subscriptions(); + assert!( + snap2.get(&community).copied().unwrap_or(0) == 0, + "community must have 0 subs after last removal; got {:?}", + snap2.get(&community) + ); + } } diff --git a/crates/buzz-relay/src/tunnel/directory.rs b/crates/buzz-relay/src/tunnel/directory.rs new file mode 100644 index 0000000000..7cd6e22a2d --- /dev/null +++ b/crates/buzz-relay/src/tunnel/directory.rs @@ -0,0 +1,922 @@ +//! Redis-backed fenced session directory for relay mesh tunnel sessions. +//! +//! Correctness law: mesh membership is only a routing hint. Redis is the +//! arbiter for session ownership, and every session-bearing frame must validate +//! its `{session_id, generation, owner_runtime_id}` fence against this directory +//! before it is accepted or forwarded. + +use std::time::Duration; + +use buzz_core::CommunityId; +use buzz_relay_mesh::{FencedHeader, MeshError, Profile, RuntimeId}; +use redis::Script; +use uuid::Uuid; + +const DEFAULT_LEASE_TTL: Duration = Duration::from_secs(30); + +const ACQUIRE_SCRIPT: &str = r#" +local lease_key = KEYS[1] +local generation_key = KEYS[2] +local owner = ARGV[1] +local profile = ARGV[2] +local ttl_ms = tonumber(ARGV[3]) + +local current = redis.call('GET', lease_key) +if current then + return {'exists', current, redis.call('GET', generation_key) or ''} +end + +local generation = redis.call('INCR', generation_key) +local value = owner .. '|' .. tostring(generation) .. '|' .. profile +redis.call('SET', lease_key, value, 'PX', ttl_ms) +return {'acquired', value, tostring(generation)} +"#; + +const RENEW_SCRIPT: &str = r#" +local lease_key = KEYS[1] +local generation_key = KEYS[2] +local owner = ARGV[1] +local generation = ARGV[2] +local ttl_ms = tonumber(ARGV[3]) + +local current = redis.call('GET', lease_key) +if not current then + return {'missing', '', redis.call('GET', generation_key) or ''} +end + +local current_owner, current_generation, current_profile = string.match(current, '^([^|]+)|([^|]+)|([^|]+)$') +if current_owner == owner and current_generation == generation then + redis.call('PEXPIRE', lease_key, ttl_ms) + return {'renewed', current, redis.call('GET', generation_key) or current_generation} +end + +return {'lost', current, redis.call('GET', generation_key) or current_generation or ''} +"#; + +const RELEASE_SCRIPT: &str = r#" +local lease_key = KEYS[1] +local generation_key = KEYS[2] +local owner = ARGV[1] +local generation = ARGV[2] + +local current = redis.call('GET', lease_key) +if not current then + return {'missing', '', redis.call('GET', generation_key) or ''} +end + +local current_owner, current_generation, current_profile = string.match(current, '^([^|]+)|([^|]+)|([^|]+)$') +if current_owner == owner and current_generation == generation then + redis.call('DEL', lease_key) + return {'released', current, redis.call('GET', generation_key) or current_generation} +end + +return {'lost', current, redis.call('GET', generation_key) or current_generation or ''} +"#; + +const VALIDATE_SCRIPT: &str = r#" +local lease_key = KEYS[1] +local generation_key = KEYS[2] + +local current = redis.call('GET', lease_key) or '' +local known_generation = redis.call('GET', generation_key) or '' +return {current, known_generation} +"#; + +/// Redis-backed owner directory for mesh tunnel sessions. +#[derive(Clone)] +pub struct SessionDirectory { + pool: deadpool_redis::Pool, + lease_ttl: Duration, +} + +/// Active session ownership lease read from Redis. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionLease { + /// Community/tenant scope for this session. + pub community_id: CommunityId, + /// Session id carried in every fenced frame. + pub session_id: Uuid, + /// Runtime currently allowed to own/send for this session generation. + pub owner_runtime_id: RuntimeId, + /// Monotonic Redis generation. Never derived from expiring lease state. + pub generation: u64, + /// Tunnel profile for the session. + pub profile: Profile, +} + +/// Result of attempting to acquire ownership for a session. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AcquireResult { + /// This caller created the lease and owns the returned generation. + Acquired(SessionLease), + /// A live lease already exists; caller must route to that owner or retry. + Exists(SessionLease), +} + +/// Result of renewing an existing owned lease. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RenewResult { + /// Lease TTL was extended. + Renewed(SessionLease), + /// Lease was absent or owned by a different fenced tuple. + Lost { + /// Live lease currently in Redis, if the lease key still exists. + current: Option, + /// Highest generation known from the non-expiring counter. + known_generation: Option, + }, +} + +/// Result of releasing an existing owned lease. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReleaseResult { + /// Lease was deleted. + Released(SessionLease), + /// Lease was absent or no longer matched this owner/generation. + NotOwner { + /// Live lease currently in Redis, if the lease key still exists. + current: Option, + /// Highest generation known from the non-expiring counter. + known_generation: Option, + }, +} + +/// Errors from the Redis session directory. +#[derive(Debug, thiserror::Error)] +pub enum DirectoryError { + /// Redis pool checkout failed. + #[error("redis pool: {0}")] + Pool(#[from] deadpool_redis::PoolError), + /// Redis command/script failed. + #[error("redis: {0}")] + Redis(#[from] redis::RedisError), + /// Redis contained a malformed lease value. + #[error("malformed session lease for {community_id}/{session_id}: {value:?}")] + MalformedLease { + /// Community/tenant scope for the malformed lease. + community_id: CommunityId, + /// Session id whose Redis value was malformed. + session_id: Uuid, + /// Raw Redis lease value. + value: String, + }, + /// Redis contained a malformed generation counter value. + #[error("malformed session generation for {community_id}/{session_id}: {value:?}")] + MalformedGeneration { + /// Community/tenant scope for the malformed counter. + community_id: CommunityId, + /// Session id whose Redis counter was malformed. + session_id: Uuid, + /// Raw Redis generation value. + value: String, + }, + /// Redis script returned an unexpected status string. + #[error("unexpected session directory script status {status:?}")] + UnexpectedScriptStatus { + /// Raw status string returned by Lua. + status: String, + }, + /// Lease TTL cannot be represented in Redis milliseconds. + #[error("lease ttl must be at least 1ms and fit in i64 milliseconds")] + InvalidLeaseTtl, +} + +impl SessionDirectory { + /// Create a directory backed by `pool` with the default lease TTL. + pub fn new(pool: deadpool_redis::Pool) -> Self { + Self::with_lease_ttl(pool, DEFAULT_LEASE_TTL) + } + + /// Create a directory backed by `pool` with an explicit lease TTL. + pub fn with_lease_ttl(pool: deadpool_redis::Pool, lease_ttl: Duration) -> Self { + Self { pool, lease_ttl } + } + + /// Attempt to create/take over the session lease. + /// + /// If no live lease exists, Redis atomically increments the companion + /// non-expiring generation key and writes the lease with the new generation. + /// If a lease exists, the generation key is not touched. + pub async fn acquire( + &self, + community_id: CommunityId, + session_id: Uuid, + owner_runtime_id: RuntimeId, + profile: Profile, + ) -> Result { + let keys = SessionKeys::new(community_id, session_id); + let ttl_ms = ttl_ms(self.lease_ttl)?; + let mut conn = self.pool.get().await?; + let (status, value, _known_generation): (String, String, String) = + Script::new(ACQUIRE_SCRIPT) + .key(&keys.lease) + .key(&keys.generation) + .arg(owner_runtime_id.to_hex()) + .arg(profile.as_wire_str()) + .arg(ttl_ms) + .invoke_async(&mut *conn) + .await?; + let lease = parse_lease(community_id, session_id, &value)?; + match status.as_str() { + "acquired" => Ok(AcquireResult::Acquired(lease)), + "exists" => Ok(AcquireResult::Exists(lease)), + _ => Err(DirectoryError::UnexpectedScriptStatus { status }), + } + } + + /// Attempt to take over a session whose previous lease is absent/expired. + /// + /// This uses the same atomic Redis path as [`Self::acquire`]: if no live + /// lease exists, the non-expiring generation counter is incremented and the + /// new lease is written in one Lua script. If a live lease exists, the + /// caller receives [`AcquireResult::Exists`] and must not proceed as owner. + pub async fn takeover( + &self, + community_id: CommunityId, + session_id: Uuid, + owner_runtime_id: RuntimeId, + profile: Profile, + ) -> Result { + self.acquire(community_id, session_id, owner_runtime_id, profile) + .await + } + + /// Renew a lease only if the current Redis value exactly matches the + /// caller's owner runtime and generation. + pub async fn renew(&self, lease: &SessionLease) -> Result { + let keys = SessionKeys::new(lease.community_id, lease.session_id); + let ttl_ms = ttl_ms(self.lease_ttl)?; + let mut conn = self.pool.get().await?; + let (status, value, known_generation): (String, String, String) = Script::new(RENEW_SCRIPT) + .key(&keys.lease) + .key(&keys.generation) + .arg(lease.owner_runtime_id.to_hex()) + .arg(lease.generation) + .arg(ttl_ms) + .invoke_async(&mut *conn) + .await?; + let current = parse_optional_lease(lease.community_id, lease.session_id, &value)?; + match status.as_str() { + "renewed" => Ok(RenewResult::Renewed( + current.expect("renewed returns lease"), + )), + "missing" | "lost" => Ok(RenewResult::Lost { + current, + known_generation: parse_optional_generation( + lease.community_id, + lease.session_id, + &known_generation, + )?, + }), + _ => Err(DirectoryError::UnexpectedScriptStatus { status }), + } + } + + /// Release a lease only if the current Redis value exactly matches the + /// caller's owner runtime and generation. + pub async fn release(&self, lease: &SessionLease) -> Result { + let keys = SessionKeys::new(lease.community_id, lease.session_id); + let mut conn = self.pool.get().await?; + let (status, value, known_generation): (String, String, String) = + Script::new(RELEASE_SCRIPT) + .key(&keys.lease) + .key(&keys.generation) + .arg(lease.owner_runtime_id.to_hex()) + .arg(lease.generation) + .invoke_async(&mut *conn) + .await?; + let current = parse_optional_lease(lease.community_id, lease.session_id, &value)?; + match status.as_str() { + "released" => Ok(ReleaseResult::Released( + current.expect("released returns lease"), + )), + "missing" | "lost" => Ok(ReleaseResult::NotOwner { + current, + known_generation: parse_optional_generation( + lease.community_id, + lease.session_id, + &known_generation, + )?, + }), + _ => Err(DirectoryError::UnexpectedScriptStatus { status }), + } + } + + /// Look up the current live lease, if any. + pub async fn lookup( + &self, + community_id: CommunityId, + session_id: Uuid, + ) -> Result, DirectoryError> { + let keys = SessionKeys::new(community_id, session_id); + let mut conn = self.pool.get().await?; + let value: Option = redis::cmd("GET") + .arg(&keys.lease) + .query_async(&mut *conn) + .await?; + value + .as_deref() + .map(|v| parse_lease(community_id, session_id, v)) + .transpose() + } + + /// Read the non-expiring generation counter for a session, if it exists. + pub async fn known_generation( + &self, + community_id: CommunityId, + session_id: Uuid, + ) -> Result, DirectoryError> { + let keys = SessionKeys::new(community_id, session_id); + let mut conn = self.pool.get().await?; + let value: Option = redis::cmd("GET") + .arg(&keys.generation) + .query_async(&mut *conn) + .await?; + match value.as_deref() { + Some(value) => parse_optional_generation(community_id, session_id, value), + None => Ok(None), + } + } + + /// Validate a session-bearing mesh frame fence against Redis. + /// + /// This is the hop-by-hop guard: a frame is accepted only when a live lease + /// exists and its owner/generation exactly match the frame. Fence-visible + /// rejections return typed [`MeshError`] variants so Wren's chaos gate can + /// distinguish `stale_generation`, `no_active_lease`, `owner_mismatch`, and + /// `future_generation`. + pub async fn validate_fenced_header( + &self, + community_id: CommunityId, + fenced: &FencedHeader, + ) -> Result<(), MeshError> { + let keys = SessionKeys::new(community_id, fenced.session_id); + let mut conn = self + .pool + .get() + .await + .map_err(|e| MeshError::Transport(format!("redis pool: {e}")))?; + let (lease_value, known_generation): (String, String) = Script::new(VALIDATE_SCRIPT) + .key(&keys.lease) + .key(&keys.generation) + .invoke_async(&mut *conn) + .await?; + let known_from_counter = + parse_optional_generation(community_id, fenced.session_id, &known_generation) + .map_err(|e| MeshError::Transport(e.to_string()))? + .unwrap_or(0); + let current = parse_optional_lease(community_id, fenced.session_id, &lease_value) + .map_err(|e| MeshError::Transport(e.to_string()))?; + let known = current + .as_ref() + .map(|lease| lease.generation) + .unwrap_or(known_from_counter) + .max(known_from_counter); + + if known > 0 && fenced.generation < known { + record_fence_rejection("stale_generation"); + return Err(MeshError::StaleGeneration { + session_id: fenced.session_id, + frame_generation: fenced.generation, + known_generation: known, + }); + } + + let Some(current) = current else { + tracing::warn!( + community_id = %community_id, + session_id = %fenced.session_id, + frame_generation = fenced.generation, + known_generation = known, + frame_owner_runtime_id = %fenced.owner_runtime_id, + "rejected fenced frame because no active session lease exists" + ); + record_fence_rejection("no_active_lease"); + return Err(MeshError::NoActiveLease { + session_id: fenced.session_id, + frame_generation: fenced.generation, + known_generation: known, + frame_owner_runtime_id: fenced.owner_runtime_id, + }); + }; + + if fenced.generation != current.generation { + tracing::warn!( + community_id = %community_id, + session_id = %fenced.session_id, + frame_generation = fenced.generation, + lease_generation = current.generation, + frame_owner_runtime_id = %fenced.owner_runtime_id, + "rejected fenced frame with generation that does not match active lease" + ); + record_fence_rejection("future_generation"); + return Err(MeshError::FutureGeneration { + session_id: fenced.session_id, + frame_generation: fenced.generation, + known_generation: current.generation, + }); + } + + if fenced.owner_runtime_id != current.owner_runtime_id { + tracing::warn!( + community_id = %community_id, + session_id = %fenced.session_id, + generation = fenced.generation, + frame_owner_runtime_id = %fenced.owner_runtime_id, + lease_owner_runtime_id = %current.owner_runtime_id, + "rejected fenced frame because owner runtime does not match active lease" + ); + record_fence_rejection("owner_mismatch"); + return Err(MeshError::OwnerMismatch { + session_id: fenced.session_id, + generation: fenced.generation, + frame_owner_runtime_id: fenced.owner_runtime_id, + current_owner_runtime_id: current.owner_runtime_id, + }); + } + + Ok(()) + } +} + +impl SessionLease { + /// Convert this lease to the fenced header carried by mesh frames. + pub fn fenced_header(&self) -> FencedHeader { + FencedHeader { + session_id: self.session_id, + generation: self.generation, + owner_runtime_id: self.owner_runtime_id, + } + } +} + +struct SessionKeys { + lease: String, + generation: String, +} + +impl SessionKeys { + fn new(community_id: CommunityId, session_id: Uuid) -> Self { + let base = format!("buzz:{}:tunnel:{}", community_id, session_id); + Self { + lease: format!("{base}:lease"), + generation: format!("{base}:generation"), + } + } +} + +trait ProfileWireExt { + fn as_wire_str(&self) -> &'static str; +} + +impl ProfileWireExt for Profile { + fn as_wire_str(&self) -> &'static str { + match self { + Profile::ReliableStream => "reliable-stream", + Profile::RealtimeMedia => "realtime-media", + Profile::HuddleControl => "huddle-control", + } + } +} + +fn record_fence_rejection(reason: &'static str) { + metrics::counter!("mesh_fence_rejections_total", "reason" => reason).increment(1); +} + +fn profile_from_wire(value: &str) -> Option { + match value { + "reliable-stream" => Some(Profile::ReliableStream), + "realtime-media" => Some(Profile::RealtimeMedia), + "huddle-control" => Some(Profile::HuddleControl), + _ => None, + } +} + +fn parse_lease( + community_id: CommunityId, + session_id: Uuid, + value: &str, +) -> Result { + let malformed = || DirectoryError::MalformedLease { + community_id, + session_id, + value: value.to_string(), + }; + let mut parts = value.split('|'); + let owner_hex = parts.next().ok_or_else(malformed)?; + let generation = parts + .next() + .ok_or_else(malformed)? + .parse::() + .map_err(|_| malformed())?; + if generation == 0 { + return Err(malformed()); + } + let profile = parts + .next() + .and_then(profile_from_wire) + .ok_or_else(malformed)?; + if parts.next().is_some() { + return Err(malformed()); + } + let owner_bytes = hex::decode(owner_hex).map_err(|_| malformed())?; + let owner_runtime_id = RuntimeId(owner_bytes.try_into().map_err(|_| malformed())?); + Ok(SessionLease { + community_id, + session_id, + owner_runtime_id, + generation, + profile, + }) +} + +fn parse_optional_lease( + community_id: CommunityId, + session_id: Uuid, + value: &str, +) -> Result, DirectoryError> { + if value.is_empty() { + Ok(None) + } else { + parse_lease(community_id, session_id, value).map(Some) + } +} + +fn parse_optional_generation( + community_id: CommunityId, + session_id: Uuid, + value: &str, +) -> Result, DirectoryError> { + if value.is_empty() { + return Ok(None); + } + let generation = value + .parse::() + .map_err(|_| DirectoryError::MalformedGeneration { + community_id, + session_id, + value: value.to_string(), + })?; + if generation == 0 { + return Err(DirectoryError::MalformedGeneration { + community_id, + session_id, + value: value.to_string(), + }); + } + Ok(Some(generation)) +} + +fn ttl_ms(ttl: Duration) -> Result { + i64::try_from(ttl.as_millis()) + .ok() + .filter(|ms| *ms > 0) + .ok_or(DirectoryError::InvalidLeaseTtl) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn community() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(0xAAAA)) + } + + fn session() -> Uuid { + Uuid::from_u128(0xBBBB) + } + + fn runtime(byte: u8) -> RuntimeId { + RuntimeId([byte; 32]) + } + + fn pool() -> deadpool_redis::Pool { + let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()); + deadpool_redis::Config::from_url(url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("create redis pool") + } + + async fn redis_directory_if_available() -> Option { + let pool = pool(); + let mut conn = pool.get().await.ok()?; + redis::cmd("PING") + .query_async::(&mut *conn) + .await + .ok()?; + Some(SessionDirectory::with_lease_ttl( + pool, + Duration::from_millis(150), + )) + } + + async fn clear_keys(directory: &SessionDirectory, community_id: CommunityId, session_id: Uuid) { + let keys = SessionKeys::new(community_id, session_id); + let mut conn = directory.pool.get().await.expect("redis conn"); + let _: () = redis::cmd("DEL") + .arg(keys.lease) + .arg(keys.generation) + .query_async(&mut *conn) + .await + .expect("clear keys"); + } + + #[test] + fn lease_value_roundtrips_profile_and_owner() { + let value = format!("{}|42|huddle-control", runtime(7).to_hex()); + let lease = parse_lease(community(), session(), &value).expect("parse lease"); + assert_eq!(lease.owner_runtime_id, runtime(7)); + assert_eq!(lease.generation, 42); + assert_eq!(lease.profile, Profile::HuddleControl); + assert_eq!(lease.fenced_header().generation, 42); + } + + #[test] + fn malformed_lease_rejects_bad_owner_and_profile() { + assert!(parse_lease(community(), session(), "not-hex|1|reliable-stream").is_err()); + assert!(parse_lease( + community(), + session(), + &format!("{}|1|bogus", runtime(1).to_hex()) + ) + .is_err()); + } + + #[test] + fn key_shape_is_community_scoped_and_separates_counter() { + let keys = SessionKeys::new(community(), session()); + assert_eq!( + keys.lease, + format!("buzz:{}:tunnel:{}:lease", community(), session()) + ); + assert_eq!( + keys.generation, + format!("buzz:{}:tunnel:{}:generation", community(), session()) + ); + assert_ne!(keys.lease, keys.generation); + } + + #[tokio::test] + async fn acquire_conflict_renew_release_and_monotonic_takeover() { + let Some(directory) = redis_directory_if_available().await else { + return; + }; + let community_id = community(); + let session_id = Uuid::new_v4(); + clear_keys(&directory, community_id, session_id).await; + + let first = match directory + .acquire( + community_id, + session_id, + runtime(1), + Profile::ReliableStream, + ) + .await + .expect("first acquire") + { + AcquireResult::Acquired(lease) => lease, + AcquireResult::Exists(_) => panic!("first acquire should win"), + }; + assert_eq!(first.generation, 1); + + let conflict = directory + .acquire( + community_id, + session_id, + runtime(2), + Profile::ReliableStream, + ) + .await + .expect("conflict acquire"); + assert!(matches!(conflict, AcquireResult::Exists(ref lease) if *lease == first)); + assert_eq!( + directory + .known_generation(community_id, session_id) + .await + .unwrap(), + Some(1) + ); + + assert!(matches!( + directory.renew(&first).await.unwrap(), + RenewResult::Renewed(_) + )); + assert!(matches!( + directory.release(&first).await.unwrap(), + ReleaseResult::Released(_) + )); + + let second = match directory + .acquire( + community_id, + session_id, + runtime(2), + Profile::ReliableStream, + ) + .await + .expect("second acquire") + { + AcquireResult::Acquired(lease) => lease, + AcquireResult::Exists(_) => panic!("released lease should be acquirable"), + }; + assert_eq!(second.generation, 2); + + assert!(matches!( + directory.renew(&first).await.unwrap(), + RenewResult::Lost { current: Some(ref lease), known_generation: Some(2) } if *lease == second + )); + assert!(matches!( + directory.release(&first).await.unwrap(), + ReleaseResult::NotOwner { current: Some(ref lease), known_generation: Some(2) } if *lease == second + )); + } + + #[tokio::test] + async fn takeover_after_ttl_expiry_increments_non_expiring_counter() { + let Some(directory) = redis_directory_if_available().await else { + return; + }; + let community_id = community(); + let session_id = Uuid::new_v4(); + clear_keys(&directory, community_id, session_id).await; + + let first = match directory + .acquire( + community_id, + session_id, + runtime(1), + Profile::ReliableStream, + ) + .await + .unwrap() + { + AcquireResult::Acquired(lease) => lease, + AcquireResult::Exists(_) => panic!("first acquire should win"), + }; + tokio::time::sleep(Duration::from_millis(220)).await; + assert_eq!( + directory.lookup(community_id, session_id).await.unwrap(), + None + ); + assert_eq!( + directory + .known_generation(community_id, session_id) + .await + .unwrap(), + Some(first.generation) + ); + + let second = match directory + .acquire( + community_id, + session_id, + runtime(2), + Profile::ReliableStream, + ) + .await + .unwrap() + { + AcquireResult::Acquired(lease) => lease, + AcquireResult::Exists(_) => panic!("expired lease should be acquirable"), + }; + assert!(second.generation > first.generation); + assert_eq!(second.generation, 2); + } + + #[tokio::test] + async fn validate_returns_typed_fence_rejections() { + let Some(directory) = redis_directory_if_available().await else { + return; + }; + let community_id = community(); + let session_id = Uuid::new_v4(); + clear_keys(&directory, community_id, session_id).await; + + let first = match directory + .acquire( + community_id, + session_id, + runtime(1), + Profile::ReliableStream, + ) + .await + .unwrap() + { + AcquireResult::Acquired(lease) => lease, + AcquireResult::Exists(_) => panic!("first acquire should win"), + }; + assert!(directory + .validate_fenced_header(community_id, &first.fenced_header()) + .await + .is_ok()); + + assert!(matches!( + directory + .validate_fenced_header( + community_id, + &FencedHeader { + owner_runtime_id: runtime(2), + ..first.fenced_header() + }, + ) + .await, + Err(MeshError::OwnerMismatch { + generation: 1, + frame_owner_runtime_id, + current_owner_runtime_id, + .. + }) if frame_owner_runtime_id == runtime(2) && current_owner_runtime_id == runtime(1) + )); + + assert!(matches!( + directory + .validate_fenced_header( + community_id, + &FencedHeader { + generation: first.generation + 1, + ..first.fenced_header() + }, + ) + .await, + Err(MeshError::FutureGeneration { + frame_generation: 2, + known_generation: 1, + .. + }) + )); + + assert!(matches!( + directory.release(&first).await.unwrap(), + ReleaseResult::Released(_) + )); + let second = match directory + .acquire( + community_id, + session_id, + runtime(2), + Profile::ReliableStream, + ) + .await + .unwrap() + { + AcquireResult::Acquired(lease) => lease, + AcquireResult::Exists(_) => panic!("second acquire should win"), + }; + assert!(matches!( + directory + .validate_fenced_header(community_id, &first.fenced_header()) + .await, + Err(MeshError::StaleGeneration { + frame_generation: 1, + known_generation: 2, + .. + }) + )); + assert!(directory + .validate_fenced_header(community_id, &second.fenced_header()) + .await + .is_ok()); + } + + #[tokio::test] + async fn validate_returns_no_active_lease_after_expiry_before_takeover() { + let Some(directory) = redis_directory_if_available().await else { + return; + }; + let community_id = community(); + let session_id = Uuid::new_v4(); + clear_keys(&directory, community_id, session_id).await; + + let lease = match directory + .acquire( + community_id, + session_id, + runtime(1), + Profile::ReliableStream, + ) + .await + .unwrap() + { + AcquireResult::Acquired(lease) => lease, + AcquireResult::Exists(_) => panic!("first acquire should win"), + }; + + tokio::time::sleep(Duration::from_millis(220)).await; + assert_eq!( + directory.lookup(community_id, session_id).await.unwrap(), + None + ); + assert!(matches!( + directory + .validate_fenced_header(community_id, &lease.fenced_header()) + .await, + Err(MeshError::NoActiveLease { + frame_generation: 1, + known_generation: 1, + frame_owner_runtime_id, + .. + }) if frame_owner_runtime_id == runtime(1) + )); + } +} diff --git a/crates/buzz-relay/src/tunnel/mod.rs b/crates/buzz-relay/src/tunnel/mod.rs new file mode 100644 index 0000000000..cc251b1f58 --- /dev/null +++ b/crates/buzz-relay/src/tunnel/mod.rs @@ -0,0 +1,8 @@ +//! Relay-side tunnel machinery for inter-relay mesh sessions. +//! +//! The mesh transport moves opaque bytes between runtimes. This module owns the +//! session layer correctness boundary: Redis-fenced ownership, strict generation +//! validation, and profile-specific routing decisions for tunnel consumers. + +pub mod directory; +pub mod reliable; diff --git a/crates/buzz-relay/src/tunnel/reliable.rs b/crates/buzz-relay/src/tunnel/reliable.rs new file mode 100644 index 0000000000..5153e104df --- /dev/null +++ b/crates/buzz-relay/src/tunnel/reliable.rs @@ -0,0 +1,950 @@ +//! Reliable-stream tunnel routing for berd ↔ goose-server sessions. +//! +//! This module owns the relay-side session decision for `Profile::ReliableStream`: +//! first join acquires the Redis fenced lease and becomes the owner runtime; +//! later joins on the owner stay local, while later joins on other runtimes open +//! a fenced mesh bi-stream to the owner. The caller remains responsible for the +//! client-facing WebSocket/bridge bytes; this module supplies the routing and +//! mesh-frame discipline shared by that handler. + +use std::sync::Arc; +use std::time::Duration; + +use buzz_core::CommunityId; +use buzz_relay_mesh::{ + FencedHeader, GoodbyeReason, MeshError, MeshStream, MeshStreamFrame, Profile, + RelayPeerTransport, RuntimeId, StreamHello, StreamRole, +}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::directory::{ + AcquireResult, DirectoryError, ReleaseResult, RenewResult, SessionDirectory, SessionLease, +}; + +/// Maximum reliable-stream application chunk per mesh frame. +/// +/// The wire cap is 16 MiB (`buzz_relay_mesh::wire::MAX_STREAM_FRAME`), but using +/// 1 MiB chunks keeps per-frame allocations bounded and comfortably supports +/// goose's 50 MiB HTTP bodies by streaming multiple ordered frames. +pub const MAX_RELIABLE_PAYLOAD_BYTES: usize = 1024 * 1024; + +/// Renewal cadence for the default 30s session lease. +const DEFAULT_RENEW_INTERVAL: Duration = Duration::from_secs(10); + +/// Relay-side router for reliable tunnel joins. +#[derive(Clone)] +pub struct ReliableStreamRouter { + directory: SessionDirectory, + transport: Arc, + local_runtime_id: RuntimeId, +} + +impl ReliableStreamRouter +where + T: RelayPeerTransport + ?Sized, +{ + /// Create a reliable-stream router from the fenced directory, mesh transport, + /// and this process's boot-unique runtime id. + pub fn new( + directory: SessionDirectory, + transport: Arc, + local_runtime_id: RuntimeId, + ) -> Self { + Self { + directory, + transport, + local_runtime_id, + } + } + + /// Return the fenced session directory used by this router. + pub fn directory(&self) -> &SessionDirectory { + &self.directory + } + + /// Return this process's local mesh runtime id. + pub fn local_runtime_id(&self) -> RuntimeId { + self.local_runtime_id + } + + /// Join a reliable-stream session from a client connected to this runtime. + /// + /// If no lease exists, this runtime becomes the owner. If the owner is this + /// runtime, the caller should pair the client locally with the owner-side + /// session hub. If another runtime owns the session, this opens a reliable + /// mesh bi-stream to that owner and sends the required `Hello` first. + pub async fn join( + &self, + community_id: CommunityId, + session_id: Uuid, + ) -> Result { + let lease = match self + .directory + .acquire( + community_id, + session_id, + self.local_runtime_id, + Profile::ReliableStream, + ) + .await? + { + AcquireResult::Acquired(lease) => return Ok(ReliableJoin::Owned { lease }), + AcquireResult::Exists(lease) => lease, + }; + + if lease.profile != Profile::ReliableStream { + return Err(ReliableStreamError::ProfileMismatch { + session_id, + expected: Profile::ReliableStream, + actual: lease.profile, + }); + } + + if lease.owner_runtime_id == self.local_runtime_id { + return Ok(ReliableJoin::Owned { lease }); + } + + let fenced = lease.fenced_header(); + let hello = StreamHello { + sender: self.local_runtime_id, + role: StreamRole::Session { + fenced, + profile: Profile::ReliableStream, + }, + }; + let stream = self + .transport + .open_session_stream(lease.owner_runtime_id, hello) + .await?; + + Ok(ReliableJoin::Forwarded { + lease, + stream: ReliableMeshStream::new(fenced, stream), + }) + } + + /// Accept an inbound mesh session stream opened by a non-owner runtime. + /// + /// The transport layer has already decoded the stream's first `Hello`; this + /// validates that it is a reliable-stream session, that the claimed sender + /// matches the authenticated peer, and that the fenced owner is local. Redis + /// validation starts on the first stateful reliable frame, which carries the + /// tenant community and is checked before payload delivery. + pub async fn accept_inbound( + &self, + from: RuntimeId, + hello: StreamHello, + stream: MeshStream, + ) -> Result { + if hello.sender != from { + return Err(ReliableStreamError::SenderMismatch { + peer: from, + hello_sender: hello.sender, + }); + } + + let StreamRole::Session { fenced, profile } = hello.role else { + return Err(ReliableStreamError::UnexpectedStreamRole); + }; + if profile != Profile::ReliableStream { + return Err(ReliableStreamError::ProfileMismatch { + session_id: fenced.session_id, + expected: Profile::ReliableStream, + actual: profile, + }); + } + + if fenced.owner_runtime_id != self.local_runtime_id { + return Err(ReliableStreamError::OwnerIsNotLocal { + session_id: fenced.session_id, + owner_runtime_id: fenced.owner_runtime_id, + local_runtime_id: self.local_runtime_id, + }); + } + + Ok(ReliableInbound { + fenced, + from, + stream: ReliableMeshStream::new_inbound(fenced, stream), + }) + } + + /// Start background lease renewal for an owner-side session. + /// + /// Losing the fenced lease is fail-loud: the worker exits after logging; the + /// caller should also validate/write through the fenced stream boundary and + /// close clients when the session layer observes loss. + pub fn spawn_renewer(&self, lease: SessionLease, cancel: CancellationToken) -> JoinHandle<()> { + self.spawn_observable_renewer(lease, cancel).task + } + + /// Start background lease renewal and return a loss signal consumers can + /// observe. + /// + /// `lost` is cancelled when this runtime loses ownership or Redis renewal + /// fails, so session consumers can tear down local state (`Room`, generation + /// floors, client bridges). Caller-initiated `cancel` is treated as normal + /// shutdown and does not trip the loss signal. + pub fn spawn_observable_renewer( + &self, + lease: SessionLease, + cancel: CancellationToken, + ) -> ReliableLeaseRenewer { + spawn_lease_renewer(self.directory.clone(), lease, cancel) + } +} + +/// Background lease renewer plus an observable ownership-loss signal. +pub struct ReliableLeaseRenewer { + /// Worker task. Await during teardown if the caller needs release completion. + pub task: JoinHandle<()>, + /// Cancelled when renewal observes loss/NotOwner or a renewal error. + pub lost: CancellationToken, +} + +/// Result of a local client joining a reliable tunnel session. +pub enum ReliableJoin { + /// This runtime owns the fenced session. Pair the client locally and renew + /// `lease` for the life of the owner-side session. + Owned { + /// Fenced ownership lease acquired by this runtime. + lease: SessionLease, + }, + /// Another runtime owns the session. Pump client bytes through `stream`. + Forwarded { + /// Current owner lease read from Redis. + lease: SessionLease, + /// Reliable mesh stream opened to the owner runtime. + stream: ReliableMeshStream, + }, +} + +/// Inbound non-owner stream accepted by the owner runtime. +pub struct ReliableInbound { + /// Fenced session tuple validated for this inbound stream. + pub fenced: FencedHeader, + /// Authenticated peer runtime that opened the stream. + pub from: RuntimeId, + /// Reliable stream wrapper pinned to `fenced`. + pub stream: ReliableMeshStream, +} + +/// A reliable mesh stream pinned to one fenced session. +pub struct ReliableMeshStream { + fenced: FencedHeader, + stream: MeshStream, + community_id: Option, +} + +impl ReliableMeshStream { + /// Wrap a raw mesh stream for one fenced reliable session. + pub fn new(fenced: FencedHeader, stream: MeshStream) -> Self { + Self { + fenced, + stream, + community_id: None, + } + } + + /// Wrap a raw inbound mesh stream. The community is latched from the first + /// stateful reliable frame before any payload is delivered. + pub fn new_inbound(fenced: FencedHeader, stream: MeshStream) -> Self { + Self::new(fenced, stream) + } + + /// Return the community/tenant latched from the first stateful frame. + pub fn community_id(&self) -> Option { + self.community_id + } + + /// Pin this outbound stream to a community before sending payload frames. + pub fn with_community(mut self, community_id: CommunityId) -> Self { + self.community_id = Some(community_id); + self + } + + /// Return the fenced tuple pinned to this stream. + pub fn fenced(&self) -> FencedHeader { + self.fenced + } + + /// Send bytes as one or more ordered mesh `Data` frames. + pub async fn send_bytes( + &mut self, + community_id: CommunityId, + bytes: &[u8], + ) -> Result<(), ReliableStreamError> { + self.ensure_outbound_community(community_id)?; + for chunk in bytes.chunks(MAX_RELIABLE_PAYLOAD_BYTES) { + let payload = ReliableWireFrame::Data { + community_id, + payload: chunk.to_vec(), + } + .encode(); + self.stream + .send_frame(MeshStreamFrame::Data { + fenced: self.fenced, + payload, + }) + .await?; + } + Ok(()) + } + + /// Finish the send half without fabricating a session frame. Used when an + /// owner-side stream is draining before the first validated frame has + /// latched its community id. + pub fn finish(&mut self) -> Result<(), ReliableStreamError> { + self.stream.finish()?; + Ok(()) + } + + /// Send a clean reliable-session close frame and finish the send half. + pub async fn send_goodbye( + &mut self, + community_id: CommunityId, + reason: GoodbyeReason, + ) -> Result<(), ReliableStreamError> { + self.ensure_outbound_community(community_id)?; + let payload = ReliableWireFrame::Goodbye { + community_id, + reason, + } + .encode(); + self.stream + .send_frame(MeshStreamFrame::Data { + fenced: self.fenced, + payload, + }) + .await?; + self.stream.finish()?; + Ok(()) + } + + /// Receive and validate the next session frame. + /// + /// Every incoming `Data`/`Goodbye` frame is checked against both the stream's + /// pinned fenced tuple and the Redis directory. This is the reliable-stream + /// equivalent of Dawn's hot-path media floor, but authoritative: stale or + /// mismatched frames fail the session rather than being dropped silently. + pub async fn recv_validated( + &mut self, + directory: &SessionDirectory, + ) -> Result, ReliableStreamError> { + let Some(frame) = self.stream.recv_frame().await? else { + return Ok(None); + }; + + match frame { + MeshStreamFrame::Data { fenced, payload } => { + let frame = ReliableWireFrame::decode(&payload)?; + let community_id = frame.community_id(); + self.validate_frame_fence(directory, community_id, &fenced) + .await?; + match frame { + ReliableWireFrame::Data { payload, .. } => { + Ok(Some(ReliableFrame::Data(payload))) + } + ReliableWireFrame::Goodbye { reason, .. } => { + Ok(Some(ReliableFrame::Goodbye(reason))) + } + } + } + MeshStreamFrame::Goodbye { .. } => Err(ReliableStreamError::UnexpectedFrame("goodbye")), + MeshStreamFrame::Hello(_) => Err(ReliableStreamError::UnexpectedFrame("hello")), + MeshStreamFrame::Gossip { .. } => Err(ReliableStreamError::UnexpectedFrame("gossip")), + } + } + + async fn validate_frame_fence( + &mut self, + directory: &SessionDirectory, + community_id: CommunityId, + fenced: &FencedHeader, + ) -> Result<(), ReliableStreamError> { + if *fenced != self.fenced { + return Err(ReliableStreamError::FrameFenceMismatch { + expected: self.fenced, + actual: *fenced, + }); + } + match self.community_id { + Some(expected) if expected != community_id => { + return Err(ReliableStreamError::CommunityMismatch { + expected, + actual: community_id, + }); + } + Some(_) | None => {} + } + directory + .validate_fenced_header(community_id, fenced) + .await?; + if self.community_id.is_none() { + self.community_id = Some(community_id); + } + Ok(()) + } + + fn ensure_outbound_community( + &mut self, + community_id: CommunityId, + ) -> Result<(), ReliableStreamError> { + match self.community_id { + Some(expected) if expected != community_id => { + Err(ReliableStreamError::CommunityMismatch { + expected, + actual: community_id, + }) + } + Some(_) => Ok(()), + None => { + self.community_id = Some(community_id); + Ok(()) + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ReliableWireFrame { + Data { + community_id: CommunityId, + payload: Vec, + }, + Goodbye { + community_id: CommunityId, + reason: GoodbyeReason, + }, +} + +impl ReliableWireFrame { + const VERSION: u8 = 1; + const DATA: u8 = 1; + const GOODBYE: u8 = 2; + + fn community_id(&self) -> CommunityId { + match self { + Self::Data { community_id, .. } | Self::Goodbye { community_id, .. } => *community_id, + } + } + + fn encode(&self) -> Vec { + match self { + Self::Data { + community_id, + payload, + } => { + let mut encoded = Vec::with_capacity(18 + payload.len()); + encoded.push(Self::VERSION); + encoded.push(Self::DATA); + encoded.extend_from_slice(community_id.as_uuid().as_bytes()); + encoded.extend_from_slice(payload); + encoded + } + Self::Goodbye { + community_id, + reason, + } => { + let mut encoded = Vec::with_capacity(19); + encoded.push(Self::VERSION); + encoded.push(Self::GOODBYE); + encoded.extend_from_slice(community_id.as_uuid().as_bytes()); + encoded.push(reason.to_wire_byte()); + encoded + } + } + } + + fn decode(bytes: &[u8]) -> Result { + if bytes.len() < 18 { + return Err(ReliableStreamError::MalformedReliableFrame("too short")); + } + if bytes[0] != Self::VERSION { + return Err(ReliableStreamError::MalformedReliableFrame( + "unknown version", + )); + } + let community_id = CommunityId::from_uuid(Uuid::from_bytes( + bytes[2..18].try_into().expect("16 byte community id slice"), + )); + match bytes[1] { + Self::DATA => Ok(Self::Data { + community_id, + payload: bytes[18..].to_vec(), + }), + Self::GOODBYE => { + if bytes.len() != 19 { + return Err(ReliableStreamError::MalformedReliableFrame( + "bad goodbye length", + )); + } + let reason = ::from_wire_byte(bytes[18])?; + Ok(Self::Goodbye { + community_id, + reason, + }) + } + _ => Err(ReliableStreamError::MalformedReliableFrame("unknown kind")), + } + } +} + +trait GoodbyeReasonWireExt { + fn to_wire_byte(self) -> u8; + fn from_wire_byte(byte: u8) -> Result; +} + +impl GoodbyeReasonWireExt for GoodbyeReason { + fn to_wire_byte(self) -> u8 { + match self { + GoodbyeReason::SessionEnded => 1, + GoodbyeReason::Draining => 2, + GoodbyeReason::StaleGeneration => 3, + } + } + + fn from_wire_byte(byte: u8) -> Result { + match byte { + 1 => Ok(GoodbyeReason::SessionEnded), + 2 => Ok(GoodbyeReason::Draining), + 3 => Ok(GoodbyeReason::StaleGeneration), + _ => Err(ReliableStreamError::MalformedReliableFrame( + "unknown goodbye reason", + )), + } + } +} + +/// Validated frame from a reliable mesh stream. +#[derive(Debug, PartialEq, Eq)] +pub enum ReliableFrame { + /// Ordered opaque bytes from the remote tunnel endpoint. + Data(Vec), + /// Clean session close with a typed reason. + Goodbye(GoodbyeReason), +} + +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum ReliableStreamError { + #[error("session directory: {0}")] + Directory(#[from] DirectoryError), + #[error(transparent)] + Mesh(#[from] MeshError), + #[error("profile mismatch for session {session_id}: expected {expected:?}, got {actual:?}")] + ProfileMismatch { + session_id: Uuid, + expected: Profile, + actual: Profile, + }, + #[error("stream hello sender {hello_sender} does not match authenticated peer {peer}")] + SenderMismatch { + peer: RuntimeId, + hello_sender: RuntimeId, + }, + #[error("unexpected non-session stream role")] + UnexpectedStreamRole, + #[error("reliable stream owner for session {session_id} is {owner_runtime_id}, not local runtime {local_runtime_id}")] + OwnerIsNotLocal { + session_id: Uuid, + owner_runtime_id: RuntimeId, + local_runtime_id: RuntimeId, + }, + #[error("unexpected {0} frame on reliable session stream")] + UnexpectedFrame(&'static str), + #[error("frame fence mismatch: expected {expected:?}, got {actual:?}")] + FrameFenceMismatch { + expected: FencedHeader, + actual: FencedHeader, + }, + #[error("community mismatch on reliable stream: expected {expected}, got {actual}")] + CommunityMismatch { + expected: CommunityId, + actual: CommunityId, + }, + #[error("malformed reliable frame: {0}")] + MalformedReliableFrame(&'static str), +} + +fn spawn_lease_renewer( + directory: SessionDirectory, + lease: SessionLease, + cancel: CancellationToken, +) -> ReliableLeaseRenewer { + spawn_lease_renewer_with_interval(directory, lease, cancel, DEFAULT_RENEW_INTERVAL) +} + +fn spawn_lease_renewer_with_interval( + directory: SessionDirectory, + lease: SessionLease, + cancel: CancellationToken, + renew_interval: Duration, +) -> ReliableLeaseRenewer { + let lost = CancellationToken::new(); + let lost_for_task = lost.clone(); + let task = tokio::spawn(async move { + let mut interval = tokio::time::interval(renew_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let caller_cancelled = loop { + tokio::select! { + _ = cancel.cancelled() => break true, + _ = interval.tick() => { + match directory.renew(&lease).await { + Ok(RenewResult::Renewed(_)) => {} + Ok(RenewResult::Lost { current, known_generation }) => { + tracing::warn!( + session_id = %lease.session_id, + owner_runtime_id = %lease.owner_runtime_id, + generation = lease.generation, + ?current, + ?known_generation, + "reliable tunnel lease renewal lost" + ); + lost_for_task.cancel(); + break false; + } + Err(err) => { + tracing::warn!( + session_id = %lease.session_id, + owner_runtime_id = %lease.owner_runtime_id, + generation = lease.generation, + error = %err, + "reliable tunnel lease renewal failed" + ); + lost_for_task.cancel(); + break false; + } + } + } + } + }; + + match directory.release(&lease).await { + Ok(ReleaseResult::Released(_)) => {} + Ok(ReleaseResult::NotOwner { + current, + known_generation, + }) => { + tracing::warn!( + session_id = %lease.session_id, + owner_runtime_id = %lease.owner_runtime_id, + generation = lease.generation, + ?current, + ?known_generation, + "reliable tunnel lease release found non-owner" + ); + lost_for_task.cancel(); + } + Err(err) => { + tracing::warn!( + session_id = %lease.session_id, + owner_runtime_id = %lease.owner_runtime_id, + generation = lease.generation, + error = %err, + "reliable tunnel lease release failed" + ); + if !caller_cancelled { + lost_for_task.cancel(); + } + } + } + }); + + ReliableLeaseRenewer { task, lost } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use buzz_relay_mesh::endpoint::MeshEndpoint; + use buzz_relay_mesh::{InboundHandler, MeshDatagram, PeerInfo}; + + use super::*; + + fn community() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(0xCAFE)) + } + + fn runtime(byte: u8) -> RuntimeId { + RuntimeId([byte; 32]) + } + + fn pool() -> deadpool_redis::Pool { + let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()); + deadpool_redis::Config::from_url(url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("create redis pool") + } + + async fn redis_directory_if_available() -> Option { + let pool = pool(); + let mut conn = pool.get().await.ok()?; + redis::cmd("PING") + .query_async::(&mut *conn) + .await + .ok()?; + Some(SessionDirectory::with_lease_ttl( + pool, + Duration::from_millis(500), + )) + } + + async fn clear_keys(directory: &SessionDirectory, community_id: CommunityId, session_id: Uuid) { + let base = format!("buzz:{}:tunnel:{}", community_id, session_id); + let _ = directory + .release(&SessionLease { + community_id, + session_id, + owner_runtime_id: runtime(1), + generation: 1, + profile: Profile::ReliableStream, + }) + .await; + let mut conn = pool().get().await.expect("redis conn"); + let _: () = redis::cmd("DEL") + .arg(format!("{base}:lease")) + .arg(format!("{base}:generation")) + .query_async(&mut *conn) + .await + .expect("clear keys"); + } + + struct NoopTransport; + + impl RelayPeerTransport for NoopTransport { + fn send_datagram(&self, _to: RuntimeId, _dgram: MeshDatagram) -> Result<(), MeshError> { + unreachable!("reliable tests do not send datagrams") + } + + fn open_session_stream( + &self, + _to: RuntimeId, + _hello: StreamHello, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async { Err(MeshError::Transport("unexpected open".into())) }) + } + + fn set_inbound(&self, _handler: Box) {} + } + + #[tokio::test] + async fn first_join_acquires_local_ownership() { + let Some(directory) = redis_directory_if_available().await else { + return; + }; + let community_id = community(); + let session_id = Uuid::new_v4(); + clear_keys(&directory, community_id, session_id).await; + + let router = ReliableStreamRouter::new(directory, Arc::new(NoopTransport), runtime(1)); + let join = router.join(community_id, session_id).await.unwrap(); + let ReliableJoin::Owned { lease } = join else { + panic!("first join owns locally") + }; + assert_eq!(lease.owner_runtime_id, runtime(1)); + assert_eq!(lease.profile, Profile::ReliableStream); + } + + struct DirectTransport { + peer: buzz_relay_mesh::peer::MeshPeer, + opened: Mutex>, + } + + impl RelayPeerTransport for DirectTransport { + fn send_datagram(&self, _to: RuntimeId, _dgram: MeshDatagram) -> Result<(), MeshError> { + unreachable!("reliable tests do not send datagrams") + } + + fn open_session_stream( + &self, + to: RuntimeId, + hello: StreamHello, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async move { + self.opened.lock().unwrap().push((to, hello.clone())); + let mut stream = self.peer.open_bi().await?; + stream.send_frame(MeshStreamFrame::Hello(hello)).await?; + Ok(stream) + }) + } + + fn set_inbound(&self, _handler: Box) {} + } + + async fn endpoint_pair() -> (MeshEndpoint, MeshEndpoint) { + let bind = || "127.0.0.1:0".parse().unwrap(); + let a = MeshEndpoint::bind(bind()).await.unwrap(); + let b = MeshEndpoint::bind(bind()).await.unwrap(); + (a, b) + } + + #[tokio::test] + async fn later_join_routes_to_remote_owner_with_reliable_hello() { + let Some(directory) = redis_directory_if_available().await else { + return; + }; + let community_id = community(); + let session_id = Uuid::new_v4(); + clear_keys(&directory, community_id, session_id).await; + + let (local_endpoint, owner_endpoint) = endpoint_pair().await; + let local_runtime = local_endpoint.runtime_id(); + let owner_runtime = owner_endpoint.runtime_id(); + let owner_addr = owner_endpoint.addr(); + let accept_endpoint = owner_endpoint.clone(); + let accept = tokio::spawn(async move { accept_endpoint.accept().await.unwrap().unwrap() }); + let local_peer = local_endpoint.connect(owner_addr).await.unwrap(); + let owner_peer = accept.await.unwrap(); + + let owner_lease = match directory + .acquire( + community_id, + session_id, + owner_runtime, + Profile::ReliableStream, + ) + .await + .unwrap() + { + AcquireResult::Acquired(lease) => lease, + AcquireResult::Exists(_) => panic!("fresh session should acquire"), + }; + + let transport = Arc::new(DirectTransport { + peer: local_peer, + opened: Mutex::new(Vec::new()), + }); + let router = ReliableStreamRouter::new(directory.clone(), transport, local_runtime); + + let recv_hello = tokio::spawn(async move { + let mut stream = owner_peer.accept_bi().await.unwrap(); + stream.recv_frame().await.unwrap().unwrap() + }); + + let join = router.join(community_id, session_id).await.unwrap(); + let ReliableJoin::Forwarded { lease, .. } = join else { + panic!("second runtime should forward") + }; + assert_eq!(lease, owner_lease); + + let got = tokio::time::timeout(Duration::from_secs(5), recv_hello) + .await + .unwrap() + .unwrap(); + assert_eq!( + got, + MeshStreamFrame::Hello(StreamHello { + sender: local_runtime, + role: StreamRole::Session { + fenced: owner_lease.fenced_header(), + profile: Profile::ReliableStream, + }, + }) + ); + } + + #[tokio::test] + async fn observable_renewer_signals_loss_when_lease_disappears() { + let Some(directory) = redis_directory_if_available().await else { + return; + }; + let community_id = community(); + let session_id = Uuid::new_v4(); + clear_keys(&directory, community_id, session_id).await; + let lease = match directory + .acquire( + community_id, + session_id, + runtime(1), + Profile::ReliableStream, + ) + .await + .unwrap() + { + AcquireResult::Acquired(lease) => lease, + AcquireResult::Exists(_) => panic!("fresh session should acquire"), + }; + + let cancel = CancellationToken::new(); + let renewer = spawn_lease_renewer_with_interval( + directory.clone(), + lease.clone(), + cancel, + Duration::from_millis(10), + ); + directory.release(&lease).await.unwrap(); + + tokio::time::timeout(Duration::from_secs(2), renewer.lost.cancelled()) + .await + .expect("lost token is cancelled after ownership loss"); + renewer.task.await.unwrap(); + } + + #[tokio::test] + async fn observable_renewer_normal_cancel_does_not_signal_loss() { + let Some(directory) = redis_directory_if_available().await else { + return; + }; + let community_id = community(); + let session_id = Uuid::new_v4(); + clear_keys(&directory, community_id, session_id).await; + let lease = match directory + .acquire( + community_id, + session_id, + runtime(1), + Profile::ReliableStream, + ) + .await + .unwrap() + { + AcquireResult::Acquired(lease) => lease, + AcquireResult::Exists(_) => panic!("fresh session should acquire"), + }; + + let cancel = CancellationToken::new(); + let renewer = spawn_lease_renewer_with_interval( + directory, + lease, + cancel.clone(), + Duration::from_millis(10), + ); + cancel.cancel(); + renewer.task.await.unwrap(); + assert!( + !renewer.lost.is_cancelled(), + "caller-initiated shutdown is not ownership loss" + ); + } + + #[test] + fn reliable_wire_frame_carries_community_without_plain_payload_changes() { + let frame = ReliableWireFrame::Data { + community_id: community(), + payload: b"goose bytes".to_vec(), + }; + let encoded = frame.encode(); + let decoded = ReliableWireFrame::decode(&encoded).unwrap(); + assert_eq!(decoded, frame); + assert_eq!(decoded.community_id(), community()); + } + + #[test] + fn payload_chunking_covers_goose_sized_bodies() { + let fifty_mib: usize = 50 * 1024 * 1024; + let chunks = fifty_mib.div_ceil(MAX_RELIABLE_PAYLOAD_BYTES); + assert_eq!(chunks, 50); + assert!(MAX_RELIABLE_PAYLOAD_BYTES < buzz_relay_mesh::wire::MAX_STREAM_FRAME as usize); + } + + #[allow(dead_code)] + fn _peer_info_is_not_an_owner_signal(_peer: PeerInfo) {} +} diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 5cb2bf118c..31493c8985 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -9,7 +9,9 @@ use buzz_core::{ KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_OPEN, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_GIT_STATUS_OPEN, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, + KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, + KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -385,15 +387,44 @@ pub fn build_edit( Ok(EventBuilder::new(Kind::Custom(40003), new_content).tags(tags)) } +/// Optional metadata for moderator delete tombstones (kind 9005). +#[derive(Debug, Clone, Default)] +pub struct DeleteMessageOptions<'a> { + /// Audit action UUID to link from the public tombstone. + pub action_id: Option, + /// Machine-readable, public-safe reason code. + pub reason_code: Option<&'a str>, + /// Human-readable reason safe for the room-facing tombstone. + pub public_reason: Option<&'a str>, +} + /// Build a Buzz-native delete event (kind 9005). pub fn build_delete_message( channel_id: Uuid, target_event_id: nostr::EventId, ) -> Result { - let tags = vec![ + build_delete_message_with_options(channel_id, target_event_id, DeleteMessageOptions::default()) +} + +/// Build a Buzz-native delete event (kind 9005) with optional moderation metadata. +pub fn build_delete_message_with_options( + channel_id: Uuid, + target_event_id: nostr::EventId, + options: DeleteMessageOptions<'_>, +) -> Result { + let mut tags = vec![ tag(&["h", &channel_id.to_string()])?, tag(&["e", &target_event_id.to_hex()])?, ]; + if let Some(action_id) = options.action_id { + tags.push(tag(&["action_id", &action_id.to_string()])?); + } + if let Some(reason_code) = options.reason_code { + tags.push(tag(&["reason_code", reason_code])?); + } + if let Some(public_reason) = options.public_reason { + tags.push(tag(&["public_reason", public_reason])?); + } Ok(EventBuilder::new(Kind::Custom(9005), "").tags(tags)) } @@ -1511,6 +1542,111 @@ pub fn build_presence_update(status: &str) -> Result { Ok(EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status).tags(tags)) } +// --------------------------------------------------------------------------- +// Community moderation commands (kinds 9040–9044). +// +// These mirror the NIP-43 relay-admin 9030-series: mod-signed command events +// that the relay validates + executes directly and never stores. The tenant +// (community) is bound by the connection host, so no `h` tag is carried — a +// stray `h` would be rejected as channel-scoping a global-only command. The +// tag vocabulary below is pinned by `moderation_commands.rs` (relay and CLI +// must agree). +// --------------------------------------------------------------------------- + +/// Build a community ban command (kind 9040). +/// +/// `expires_at`: `None` ⇒ permanent; `Some(unix_secs)` ⇒ ban lifts at that time. +pub fn build_moderation_ban( + target_pubkey: &str, + expires_at: Option, + reason: Option<&str>, +) -> Result { + let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?; + let mut tags = vec![tag(&["p", &target_pubkey])?]; + if let Some(exp) = expires_at { + tags.push(tag(&["expiration", &exp.to_string()])?); + } + if let Some(r) = reason { + tags.push(tag(&["reason", r])?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_BAN as u16), "").tags(tags)) +} + +/// Build a community unban command (kind 9041). +pub fn build_moderation_unban(target_pubkey: &str) -> Result { + let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?; + let tags = vec![tag(&["p", &target_pubkey])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_UNBAN as u16), "").tags(tags)) +} + +/// Build a community timeout (write-block) command (kind 9042). +/// +/// `expires_at` (required) is the unix-seconds timestamp the timeout lifts at. +pub fn build_moderation_timeout( + target_pubkey: &str, + expires_at: u64, + reason: Option<&str>, +) -> Result { + let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?; + let mut tags = vec![ + tag(&["p", &target_pubkey])?, + tag(&["expiration", &expires_at.to_string()])?, + ]; + if let Some(r) = reason { + tags.push(tag(&["reason", r])?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_TIMEOUT as u16), "").tags(tags)) +} + +/// Build a community untimeout command (kind 9043). +pub fn build_moderation_untimeout(target_pubkey: &str) -> Result { + let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?; + let tags = vec![tag(&["p", &target_pubkey])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_UNTIMEOUT as u16), "").tags(tags)) +} + +/// Build a resolve-report command (kind 9044). +/// +/// `report_event_id`: hex event id of the kind:1984 report being resolved. +/// `status`: `resolved` | `dismissed`. `action`: `delete` | `kick` | `ban` | +/// `timeout` | `dismiss` | `escalate` (`dismiss` pairs with `dismissed`, +/// everything else with `resolved` — the relay enforces the pairing). +/// `reason`: optional; audited into `public_reason` and relayed in the +/// reporter notice DM, so it must be safe for the reporter to read. +pub fn build_moderation_resolve_report( + report_event_id: &str, + status: &str, + action: &str, + reason: Option<&str>, +) -> Result { + let report_event_id = check_hex_exact(report_event_id, 64, "report_event_id")?; + match status { + "resolved" | "dismissed" => {} + _ => { + return Err(SdkError::InvalidInput(format!( + "status must be resolved or dismissed (got: {status})" + ))) + } + } + match action { + "delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" => {} + _ => { + return Err(SdkError::InvalidInput(format!( + "action must be delete, kick, ban, timeout, dismiss, or escalate (got: {action})" + ))) + } + } + let mut tags = vec![ + tag(&["report", &report_event_id])?, + tag(&["status", status])?, + tag(&["action", action])?, + ]; + if let Some(r) = reason { + tags.push(tag(&["reason", r])?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_RESOLVE_REPORT as u16), "").tags(tags)) +} + #[cfg(test)] mod tests { use super::*; @@ -1884,6 +2020,31 @@ mod tests { assert_eq!(ev.content, ""); } + #[test] + fn delete_message_with_moderation_metadata() { + let cid = uuid(); + let eid = event_id(); + let action_id = Uuid::new_v4(); + let ev = sign( + build_delete_message_with_options( + cid, + eid, + DeleteMessageOptions { + action_id: Some(action_id), + reason_code: Some("spam"), + public_reason: Some("Removed for spam."), + }, + ) + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 9005); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert!(has_tag(&ev, "e", &eid.to_hex())); + assert!(has_tag(&ev, "action_id", &action_id.to_string())); + assert!(has_tag(&ev, "reason_code", "spam")); + assert!(has_tag(&ev, "public_reason", "Removed for spam.")); + } + #[test] fn delete_compat_happy_path() { let cid = uuid(); @@ -3134,4 +3295,122 @@ mod tests { let err = build_git_pr_update(&pr_repo(), "", &meta).unwrap_err(); assert!(matches!(err, SdkError::InvalidInput(_))); } + + // --- community moderation commands (9040–9044) ------------------------ + + #[test] + fn moderation_ban_permanent() { + let pk = "a".repeat(64); + let ev = sign(build_moderation_ban(&pk, None, None).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_BAN as u16); + assert!(has_tag(&ev, "p", &pk)); + assert!(tag_values(&ev, "expiration").is_empty()); + assert!(tag_values(&ev, "reason").is_empty()); + } + + #[test] + fn moderation_ban_temporary_with_reason() { + let pk = "b".repeat(64); + let ev = sign(build_moderation_ban(&pk, Some(1783500000), Some("spam")).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_BAN as u16); + assert!(has_tag(&ev, "p", &pk)); + assert!(has_tag(&ev, "expiration", "1783500000")); + assert!(has_tag(&ev, "reason", "spam")); + } + + #[test] + fn moderation_ban_lowercases_pubkey() { + let ev = sign(build_moderation_ban(&"A".repeat(64), None, None).unwrap()); + assert!(has_tag(&ev, "p", &"a".repeat(64))); + } + + #[test] + fn moderation_ban_rejects_short_pubkey() { + let err = build_moderation_ban("abc", None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn moderation_ban_rejects_overlong_pubkey() { + // Relay `extract_p_tag_bytes` requires exactly 64 hex; the SDK must + // reject 65+ hex here rather than sign a `p` tag the relay drops. + let err = build_moderation_ban(&"a".repeat(65), None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn moderation_unban_shape() { + let pk = "c".repeat(64); + let ev = sign(build_moderation_unban(&pk).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_UNBAN as u16); + assert!(has_tag(&ev, "p", &pk)); + } + + #[test] + fn moderation_timeout_shape() { + let pk = "d".repeat(64); + let ev = sign(build_moderation_timeout(&pk, 1783500000, Some("cool off")).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_TIMEOUT as u16); + assert!(has_tag(&ev, "p", &pk)); + assert!(has_tag(&ev, "expiration", "1783500000")); + assert!(has_tag(&ev, "reason", "cool off")); + } + + #[test] + fn moderation_untimeout_shape() { + let pk = "e".repeat(64); + let ev = sign(build_moderation_untimeout(&pk).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_UNTIMEOUT as u16); + assert!(has_tag(&ev, "p", &pk)); + } + + #[test] + fn moderation_resolve_shape() { + let rid = event_id().to_hex(); + let ev = + sign(build_moderation_resolve_report(&rid, "resolved", "ban", Some("rule 3")).unwrap()); + assert_eq!(ev.kind.as_u16(), KIND_MODERATION_RESOLVE_REPORT as u16); + assert!(has_tag(&ev, "report", &rid)); + assert!(has_tag(&ev, "status", "resolved")); + assert!(has_tag(&ev, "action", "ban")); + assert!(has_tag(&ev, "reason", "rule 3")); + } + + #[test] + fn moderation_resolve_dismiss_no_reason() { + let rid = event_id().to_hex(); + let ev = sign(build_moderation_resolve_report(&rid, "dismissed", "dismiss", None).unwrap()); + assert!(has_tag(&ev, "status", "dismissed")); + assert!(has_tag(&ev, "action", "dismiss")); + assert!(tag_values(&ev, "reason").is_empty()); + } + + #[test] + fn moderation_resolve_rejects_bad_status() { + let rid = event_id().to_hex(); + let err = build_moderation_resolve_report(&rid, "escalated", "ban", None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn moderation_resolve_rejects_bad_action() { + let rid = event_id().to_hex(); + let err = build_moderation_resolve_report(&rid, "resolved", "nuke", None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn moderation_resolve_rejects_short_report_id() { + let err = build_moderation_resolve_report("abc", "resolved", "ban", None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn moderation_resolve_rejects_overlong_report_id() { + // Relay `extract_report_tag` requires exactly 64 hex; the SDK must + // reject 65+ hex here rather than sign a `report` tag the relay drops. + let err = + build_moderation_resolve_report(&"a".repeat(65), "resolved", "ban", None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } } diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 52cf773f01..675d15db8b 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -2,9 +2,9 @@ //! //! Run with a local PG: `BUZZ_TEST_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz cargo test -p buzz-search --tests -- --include-ignored` //! -//! Each test creates a uniquely-named schema, applies all five migrations in -//! order (0001 → 0002 → 0003 → 0004 → 0005) into it, exercises a scenario, and drops -//! it. Tests are parallel-safe. +//! Each test creates a uniquely-named schema, applies every FTS-affecting +//! migration in order, exercises a scenario, and drops it. Tests are +//! parallel-safe. use buzz_core::{ kind::{ @@ -23,6 +23,11 @@ const MIGRATION_0002_SQL: &str = include_str!("../../../migrations/0002_git_repo const MIGRATION_0003_SQL: &str = include_str!("../../../migrations/0003_community_icon.sql"); const MIGRATION_0004_SQL: &str = include_str!("../../../migrations/0004_events_tags_gin.sql"); const MIGRATION_0005_SQL: &str = include_str!("../../../migrations/0005_agent_turn_metric_fts.sql"); +const MIGRATION_0006_SQL: &str = include_str!("../../../migrations/0006_moderation.sql"); +const MIGRATION_0007_SQL: &str = include_str!("../../../migrations/0007_nip_rs_retention.sql"); +const MIGRATION_0008_SQL: &str = + include_str!("../../../migrations/0008_fresh_install_search_allowlist.sql"); +const MIGRATION_0014_SQL: &str = include_str!("../../../migrations/0014_push_lease_fts.sql"); async fn setup() -> (PgPool, String) { let url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); @@ -64,6 +69,18 @@ async fn setup() -> (PgPool, String) { pool.execute(MIGRATION_0005_SQL) .await .expect("apply 0005 migration"); + pool.execute(MIGRATION_0006_SQL) + .await + .expect("apply 0006 migration"); + pool.execute(MIGRATION_0007_SQL) + .await + .expect("apply 0007 migration"); + pool.execute(MIGRATION_0008_SQL) + .await + .expect("apply 0008 migration"); + pool.execute(MIGRATION_0014_SQL) + .await + .expect("apply 0014 migration"); (pool, schema) } @@ -148,7 +165,7 @@ async fn search_finds_event_in_same_community() { c_a, evt_id, pk, - 1, + 9, "hello wonderland — buzz everyone", None, 1700000000, @@ -174,7 +191,7 @@ async fn search_finds_event_in_same_community() { assert_eq!(result.hits.len(), 1); assert_eq!(result.hits[0].event_id, evt_id); - assert_eq!(result.hits[0].kind, 1); + assert_eq!(result.hits[0].kind, 9); assert_eq!(result.hits[0].created_at, 1700000000); assert!(result.hits[0].rank > 0.0); @@ -196,7 +213,7 @@ async fn search_does_not_return_other_community_events() { c_a, rand_bytes32(), pk, - 1, + 9, "only-in-a unique-token-xyz", None, 1700000000, @@ -547,7 +564,7 @@ async fn channel_scope_restricts_results() { c, rand_bytes32(), pk, - 1, + 9, "shared-token in ch-a", Some(ch_a), 1700000000, @@ -558,7 +575,7 @@ async fn channel_scope_restricts_results() { c, rand_bytes32(), pk, - 1, + 9, "shared-token in ch-b", Some(ch_b), 1700000001, @@ -569,7 +586,7 @@ async fn channel_scope_restricts_results() { c, rand_bytes32(), pk, - 1, + 9, "shared-token global", None, 1700000002, @@ -662,7 +679,7 @@ async fn deleted_events_are_excluded() { let c = mk_community(&pool, "a.example").await; let evt_id = rand_bytes32(); let pk = rand_bytes32(); - insert_event(&pool, c, evt_id, pk, 1, "deleted-token-q", None, 1700000000).await; + insert_event(&pool, c, evt_id, pk, 9, "deleted-token-q", None, 1700000000).await; // Soft-delete sqlx::query("UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2") @@ -740,7 +757,7 @@ async fn since_until_filters() { c, rand_bytes32(), pk, - 1, + 9, "time-token-zz at A", None, 1_700_000_000, @@ -751,7 +768,7 @@ async fn since_until_filters() { c, rand_bytes32(), pk, - 1, + 9, "time-token-zz at B", None, 1_700_010_000, @@ -762,7 +779,7 @@ async fn since_until_filters() { c, rand_bytes32(), pk, - 1, + 9, "time-token-zz at C", None, 1_700_020_000, @@ -805,7 +822,7 @@ async fn pagination_works() { c, rand_bytes32(), pk, - 1, + 9, "page-token-qq", None, 1_700_000_000 + i, @@ -893,7 +910,7 @@ async fn channel_less_only_excludes_per_channel_events() { c, rand_bytes32(), pk, - 1, + 9, "fence-token in ch-a", Some(ch_a), 1_700_000_000, @@ -904,7 +921,7 @@ async fn channel_less_only_excludes_per_channel_events() { c, rand_bytes32(), pk, - 1, + 9, "fence-token in ch-b", Some(ch_b), 1_700_000_001, @@ -915,7 +932,7 @@ async fn channel_less_only_excludes_per_channel_events() { c, rand_bytes32(), pk, - 1, + 9, "fence-token channel-less", None, 1_700_000_002, diff --git a/crates/buzz-test-client/tests/e2e_managed_agent.rs b/crates/buzz-test-client/tests/e2e_managed_agent.rs index a84ab185a5..ca5373a2ed 100644 --- a/crates/buzz-test-client/tests/e2e_managed_agent.rs +++ b/crates/buzz-test-client/tests/e2e_managed_agent.rs @@ -41,16 +41,34 @@ fn sub_id(name: &str) -> String { /// `desktop/src-tauri/src/managed_agents/agent_events.rs`). Built inline here /// because the e2e crate does not depend on the desktop crate; the /// projection-function exclusion contract is unit-tested in that module. This -/// is exactly the field set the desktop publishes — secrets, the backend blob, -/// env vars, and runtime fields are absent by construction. +/// is exactly the field set the desktop publishes for a definition-less +/// record (a definition-linked one omits the definition quad — the slimmed +/// NIP-AP shape; the relay accepts both, as the legacy-fat test below proves) — +/// secrets, the backend blob, env vars, and runtime fields are absent by +/// construction. fn agent_projection_content(name: &str) -> String { + serde_json::json!({ + "name": name, + "system_prompt": "You are a test agent.", + "model": "claude-opus-4", + "provider": "anthropic", + "parallelism": 24, + "respond_to": "allowlist", + "respond_to_allowlist": ["79be667e"] + }) + .to_string() +} + +/// A legacy "fat" definition-linked projection (pre-slimming shape): carries +/// both `persona_id` and the definition quad. Old clients still publish this; +/// the relay must keep accepting it. +fn legacy_fat_agent_projection_content(name: &str) -> String { serde_json::json!({ "name": name, "persona_id": "persona-1", "system_prompt": "You are a test agent.", "model": "claude-opus-4", "provider": "anthropic", - "mcp_toolsets": "default", "persona_source_version": "abc123", "parallelism": 24, "respond_to": "allowlist", @@ -134,6 +152,27 @@ async fn test_managed_agent_publish_and_query() { client.disconnect().await.expect("disconnect"); } +/// The relay keeps accepting the legacy "fat" definition-linked projection +/// (pre-slimming shape) — old clients publish it during the transition. +#[tokio::test] +#[ignore] +async fn test_legacy_fat_agent_event_still_accepted() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = agent_d_tag(); + let content = legacy_fat_agent_projection_content("Legacy Agent"); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let event = agent_event(&keys, &d_tag, &content); + let ok = client.send_event(event).await.expect("send legacy agent"); + assert!( + ok.accepted, + "relay rejected legacy fat managed-agent event: {}", + ok.message + ); + client.disconnect().await.expect("disconnect"); +} + /// The relay round-trips published content byte-for-byte: a projection-shaped /// body goes out, and the relay returns exactly those fields and nothing more. /// The body here is a hand-built secret-free literal (`agent_projection_content`), diff --git a/crates/buzz-test-client/tests/e2e_mesh_llm.rs b/crates/buzz-test-client/tests/e2e_mesh_llm.rs index aadc049f6b..4b1bfb3d21 100644 --- a/crates/buzz-test-client/tests/e2e_mesh_llm.rs +++ b/crates/buzz-test-client/tests/e2e_mesh_llm.rs @@ -1,28 +1,26 @@ -//! End-to-end acceptance tests for the relay-hosted mesh-LLM feature. +//! End-to-end acceptance tests for Buzz shared compute. //! -//! These tests require a running buzz-relay with mesh embedded -//! (`BUZZ_MESH_ENABLED=true`, `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`) and, -//! for the live-inference rows, two desktop mesh nodes (serve + client). +//! These tests require a membership-gated buzz-relay and a mesh-enabled desktop +//! publishing its client-signed discovery note. Live-inference rows additionally +//! require two desktop mesh nodes (serve + client). //! All tests are `#[ignore]` by default — they need infra CI does not host //! (native llama, multi-node, model download). The deterministic trust -//! invariants are unit-tested in `buzz-relay` (`mesh_status_publisher`, -//! `mesh_signaling`); this file is the opt-in full-stack acceptance layer. +//! invariants are unit-tested in the desktop mesh module; this file is the +//! opt-in full-stack acceptance layer. //! //! # Running (manual / runbook) //! //! ```text -//! # 1. one-time local llama build (see docs/mesh-llm-local-build.md) -//! # 2. start a mesh-enabled relay -//! BUZZ_MESH_ENABLED=true BUZZ_REQUIRE_RELAY_MEMBERSHIP=true \ -//! cargo run -p buzz-relay -//! # 3. run the trust assertions (no GPU needed): +//! # 1. prepare the matching native runtime with `scripts/ensure-mesh-native-runtime.sh` +//! # 2. start the normal membership-gated relay and a mesh-enabled desktop +//! # 3. have that desktop publish status, then run the trust assertions: //! RELAY_URL=ws://localhost:3000 \ //! cargo test --test e2e_mesh_llm trust -- --ignored --nocapture //! # 4. run the live A->B inference row (needs 2 mesh nodes + a small model): //! # point at B's local OpenAI endpoint; without it the test SKIPS (no silent pass): //! MESH_OPENAI_BASE=http://127.0.0.1:9337/v1 \ //! cargo test --test e2e_mesh_llm live_agent_completes -- --ignored --nocapture -//! # trust rows need real relay identities, not generated keys: +//! # MEMBER_NSEC is the publishing desktop identity; STRANGER_NSEC is not a member: //! MEMBER_NSEC=nsec1... STRANGER_NSEC=nsec1... \ //! cargo test --test e2e_mesh_llm trust -- --ignored --nocapture //! ``` @@ -30,9 +28,9 @@ //! ## Acceptance matrix (= the demo, as a test) //! | # | Assertion | This file | Also covered by | //! |---|-----------|-----------|-----------------| -//! | 1 | member reads kind:30621 status w/ dial pointer, no secrets | `trust_member_reads_mesh_status` | relay `mesh_status_publisher` units | -//! | 2 | non-member REQ for kind:30621 returns nothing | `trust_nonmember_read_denied` | — | -//! | 3 | non-member iroh dial denied (NIP-98→membership) | runbook (needs iroh dial) | relay `mesh_signaling` admission units | +//! | 1 | member reads its kind:30003 owner-bound status, no secrets | `trust_member_reads_mesh_status` | desktop discovery units | +//! | 2 | non-member REQ for kind:30003 returns nothing | `trust_nonmember_read_denied` | relay membership tests | +//! | 3 | untrusted Mesh owner cannot infer with a leaked join token | hardware harness | `mesh_admission_smoke` | //! | 4 | B's agent completes a chat against A's model over mesh | `live_agent_completes_chat_over_mesh` | runbook | //! | 5 | dropped member → typed auth failure reaches lastError | runbook (desktop harness) | buzz-agent `-32001` unit | //! | 6 | split: model too big → 2 serve nodes → chat completes | `live_split_model_completes` | runbook | @@ -40,11 +38,11 @@ use std::time::Duration; use buzz_test_client::BuzzTestClient; -use nostr::{Filter, Keys, Kind}; +use nostr::{Alphabet, Filter, Keys, Kind, SingleLetterTag}; -/// Buzz's relay-owned mesh status kind (must match `buzz_core::kind`). -const KIND_MESH_LLM_RELAY_STATUS: u16 = 30621; -const MESH_STATUS_D_TAG: &str = "buzz-relay-mesh"; +/// NIP-51 bookmark set used for client-owned Mesh discovery notes. +const KIND_BUZZ_MESH_MEMBER_STATUS: u16 = 30003; +const MESH_STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status:"; const MESH_STATUS_TYPE: &str = "buzz-mesh-status"; fn relay_url() -> String { @@ -81,14 +79,13 @@ fn sub_id(name: &str) -> String { fn mesh_status_filter() -> Filter { Filter::new() - .kind(Kind::Custom(KIND_MESH_LLM_RELAY_STATUS)) - .identifier(MESH_STATUS_D_TAG) + .kind(Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::K), MESH_STATUS_TYPE) } -/// Assertion 1: an authenticated relay member can REQ the relay-signed -/// kind:30621 status event; its content carries the sanitized projection -/// (mesh/models/serveTargets with EndpointAddr dial pointers) and NO secrets -/// (no invite-secret, no local paths, no raw runtime object). +/// Assertion 1: an authenticated relay member can read its own client-signed +/// kind:30003 status. The content carries an owner binding and EndpointAddr dial +/// pointers, but no secret keys or local paths. /// /// Requires a mesh-enabled relay that has published at least one status event. #[tokio::test] @@ -114,19 +111,34 @@ async fn trust_member_reads_mesh_status() { let status = events .iter() - .find(|e| e.kind == Kind::Custom(KIND_MESH_LLM_RELAY_STATUS)) - .expect("a member must see at least one kind:30621 status event"); + .find(|e| { + e.kind == Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS) && e.pubkey == member.public_key() + }) + .expect("the publishing member must see its kind:30003 mesh status event"); - // Relay-signed (the relay keypair, not this member). - assert_ne!( + assert_eq!( status.pubkey, member.public_key(), - "status must be relay-signed, not member-signed" + "status must be signed by the desktop member, not the relay" ); + assert!(status.tags.iter().any(|tag| { + let values = tag.as_slice(); + values.first().map(String::as_str) == Some("d") + && values + .get(1) + .is_some_and(|value| value.starts_with(MESH_STATUS_D_TAG_PREFIX)) + })); let content: serde_json::Value = serde_json::from_str(&status.content).expect("content is JSON"); - assert_eq!(content["type"], MESH_STATUS_TYPE, "type discriminator"); + for field in ["ownerId", "ownerVerifyingKey", "ownerBindingSig"] { + assert!( + content[field] + .as_str() + .is_some_and(|value| !value.trim().is_empty()), + "status carries {field}" + ); + } // Dial pointer present (EndpointAddr is connectivity, not a secret). let targets = content["serveTargets"] @@ -159,7 +171,7 @@ async fn trust_member_reads_mesh_status() { } /// Assertion 2: a valid Nostr identity that is NOT a relay member gets nothing -/// back for a kind:30621 REQ — membership gates the read. +/// back for a kind:30003 mesh-status REQ — membership gates the read. /// /// Requires a relay with `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true` and a published /// status event that members can see (paired with assertion 1). @@ -189,10 +201,10 @@ async fn trust_nonmember_read_denied() { let leaked = events .iter() - .any(|e| e.kind == Kind::Custom(KIND_MESH_LLM_RELAY_STATUS)); + .any(|e| e.kind == Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS)); assert!( !leaked, - "non-member must NOT receive kind:30621 mesh status" + "non-member must NOT receive kind:30003 mesh status" ); client.disconnect().await.ok(); diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs index 5054018869..54a0f19c30 100644 --- a/crates/buzz-test-client/tests/e2e_persona.rs +++ b/crates/buzz-test-client/tests/e2e_persona.rs @@ -63,18 +63,17 @@ async fn test_persona_publish_and_query() { // Publish persona event let event = persona_event(&keys, &d_tag, &content); + let event_id = event.id; let ok = client .send_event(event.clone()) .await .expect("send persona"); assert!(ok.accepted, "relay rejected persona event: {}", ok.message); - // Query it back using NIP-33 filter (kind + author + d-tag) + // Query the exact event this test published. Replacement-address filters + // are exercised below and may legitimately replay a racing duplicate. let sid = sub_id("query"); - let filter = Filter::new() - .kind(Kind::Custom(PERSONA_KIND)) - .author(keys.public_key()) - .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]); + let filter = Filter::new().id(event_id); client .subscribe(&sid, vec![filter]) @@ -86,8 +85,10 @@ async fn test_persona_publish_and_query() { .await .expect("collect events"); - assert_eq!(events.len(), 1, "expected exactly one persona event"); - let ev = &events[0]; + let ev = events + .iter() + .find(|candidate| candidate.id == event_id) + .expect("published persona event was not returned"); assert_eq!(ev.content, content); assert_eq!(ev.pubkey, keys.public_key()); assert_eq!(ev.kind, Kind::Custom(PERSONA_KIND)); @@ -95,6 +96,88 @@ async fn test_persona_publish_and_query() { client.disconnect().await.expect("disconnect"); } +/// NIP-AP revision: a prompt-less definition (display_name only) must ingest +/// through the REAL relay path and round-trip byte-for-byte — not just pass +/// the envelope validator helper. +#[tokio::test] +#[ignore] +async fn test_promptless_persona_ingests_and_round_trips() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = format!("promptless-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let content = serde_json::json!({ "display_name": "Config Only" }).to_string(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let event = persona_event(&keys, &d_tag, &content); + let ok = client.send_event(event).await.expect("send promptless"); + assert!( + ok.accepted, + "relay rejected prompt-less persona: {}", + ok.message + ); + + let sid = sub_id("promptless"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(keys.public_key()) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].content, content, "byte-for-byte round-trip"); + client.disconnect().await.expect("disconnect"); +} + +/// NIP-AP revision: the reserved behavioral fields ingest through the real +/// relay path as opaque content and round-trip byte-for-byte. +#[tokio::test] +#[ignore] +async fn test_behavioral_fields_persona_ingests_and_round_trips() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = format!("behavioral-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let content = serde_json::json!({ + "display_name": "Behavioral", + "system_prompt": "p", + "respond_to": "owner-only", + "respond_to_allowlist": [], + "parallelism": 2 + }) + .to_string(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let event = persona_event(&keys, &d_tag, &content); + let ok = client.send_event(event).await.expect("send behavioral"); + assert!( + ok.accepted, + "relay rejected behavioral-fields persona: {}", + ok.message + ); + + let sid = sub_id("behavioral"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(keys.public_key()) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].content, content, "byte-for-byte round-trip"); + client.disconnect().await.expect("disconnect"); +} + #[tokio::test] #[ignore] async fn test_persona_nip33_replacement_newer_wins() { diff --git a/crates/buzz-workflow/Cargo.toml b/crates/buzz-workflow/Cargo.toml index 6e213c505f..d4813e56d4 100644 --- a/crates/buzz-workflow/Cargo.toml +++ b/crates/buzz-workflow/Cargo.toml @@ -18,6 +18,7 @@ dashmap = { workspace = true } moka = { workspace = true } evalexpr = "11" cron = "0.16" +nostr = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tokio = { workspace = true } @@ -25,8 +26,5 @@ tracing = { workspace = true } thiserror = { workspace = true } reqwest = { workspace = true, optional = true } -[dev-dependencies] -nostr = { workspace = true } - [features] reqwest = ["dep:reqwest"] diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index feceec07b7..a029b44622 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use buzz_core::tenant::CommunityId; use evalexpr::HashMapContext; +use nostr::ToBech32; use serde_json::Value as JsonValue; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -62,7 +63,8 @@ impl TriggerContext { /// /// Supports filters: /// - `| truncate(N)` — truncate to N characters -/// - `| truncate_pubkey` — shorten pubkey to `abc...xyz` (first 6 + last 6 chars) +/// - `| npub` — encode a hex pubkey as its full bech32 `npub` (non-pubkey +/// values pass through unchanged); `truncate_pubkey` is a legacy alias /// /// Unknown `{{keys}}` are left as literal text (no error, no substitution). pub fn resolve_template( @@ -185,28 +187,12 @@ fn apply_filter(value: String, filter: &str) -> Result { return Ok(truncated); } - // `truncate_pubkey` — shorten to `abc...xyz` (first 6 + last 6 chars). - // Only skip truncation if the string is shorter than the truncated form would be. - if filter == "truncate_pubkey" { - let char_count = value.chars().count(); - if char_count <= 12 { - // Already short enough that truncating would be longer than the original. - // But we still apply the format for consistency if exactly 12. - // For strings < 12 chars, return as-is. - if char_count < 12 { - return Ok(value); - } + // `npub` (alias `truncate_pubkey`): full bech32 npub — truncated prefixes are grindable. + if filter == "npub" || filter == "truncate_pubkey" { + if let Ok(pk) = nostr::PublicKey::from_hex(&value) { + return Ok(pk.to_bech32().unwrap_or(value)); } - let head: String = value.chars().take(6).collect(); - let tail: String = value - .chars() - .rev() - .take(6) - .collect::() - .chars() - .rev() - .collect(); - return Ok(format!("{head}...{tail}")); + return Ok(value); } Err(WorkflowError::TemplateError(format!( @@ -1284,15 +1270,30 @@ mod tests { } #[test] - fn resolve_truncate_pubkey_filter() { - let ctx = make_trigger(); + fn resolve_npub_filter_encodes_hex_pubkey() { + let mut ctx = make_trigger(); + ctx.author = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f".to_owned(); + let out = resolve_template("{{trigger.author | npub}}", &ctx, &HashMap::new()).unwrap(); + assert_eq!( + out, + "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux" + ); + } + + #[test] + fn resolve_truncate_pubkey_is_alias_for_npub() { + let mut ctx = make_trigger(); + ctx.author = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f".to_owned(); let out = resolve_template( "{{trigger.author | truncate_pubkey}}", &ctx, &HashMap::new(), ) .unwrap(); - assert_eq!(out, "abc123...def456"); + assert_eq!( + out, + "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux" + ); } #[test] @@ -1543,10 +1544,10 @@ mod tests { } #[test] - fn resolve_truncate_pubkey_short_string_returned_as_is() { - // Strings shorter than 12 chars are returned as-is (no truncation). + fn resolve_pubkey_filter_non_pubkey_passes_through() { + // Values that are not valid hex pubkeys are returned unchanged. let mut ctx = make_trigger(); - ctx.author = "short".to_owned(); // 5 chars < 12 + ctx.author = "short".to_owned(); let out = resolve_template( "{{trigger.author | truncate_pubkey}}", &ctx, @@ -1557,17 +1558,12 @@ mod tests { } #[test] - fn resolve_truncate_pubkey_exactly_12_chars() { - // Exactly 12 chars → format as head...tail (6+6). + fn resolve_npub_filter_passes_npub_through() { + // Already-encoded npubs are not valid hex, so they pass through intact. let mut ctx = make_trigger(); - ctx.author = "abcdef123456".to_owned(); // exactly 12 chars - let out = resolve_template( - "{{trigger.author | truncate_pubkey}}", - &ctx, - &HashMap::new(), - ) - .unwrap(); - assert_eq!(out, "abcdef...123456"); + ctx.author = "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux".to_owned(); + let out = resolve_template("{{trigger.author | npub}}", &ctx, &HashMap::new()).unwrap(); + assert_eq!(out, ctx.author); } #[test] diff --git a/deny.toml b/deny.toml index 2f54aa1840..d3c5fcd4bc 100644 --- a/deny.toml +++ b/deny.toml @@ -35,6 +35,9 @@ allow = [ "MPL-2.0", "BSL-1.0", "Unlicense", + # minicbor's permissive, OSI-approved license. Used for strict App Attest + # assertion CBOR parsing and also transitively by appattest. + "BlueOak-1.0.0", # bzip2/libbzip2's permissive BSD-like license. New via desktop zip/bzip2 # transitive deps; compatible with Apache-2.0 distribution. "bzip2-1.0.6", diff --git a/deploy/charts/buzz-push-gateway/Chart.yaml b/deploy/charts/buzz-push-gateway/Chart.yaml new file mode 100644 index 0000000000..4035fdce35 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +# Published to oci://ghcr.io/block/buzz/charts via push-chart-release/ +# branches (see docs/push-gateway-deployment.md, "Gateway chart release"). +name: buzz-push-gateway +description: Public capability-gated APNs last-hop gateway for Buzz +version: 0.1.0 +appVersion: "0.1.0" +type: application diff --git a/deploy/charts/buzz-push-gateway/templates/_helpers.tpl b/deploy/charts/buzz-push-gateway/templates/_helpers.tpl new file mode 100644 index 0000000000..5603ab1114 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/templates/_helpers.tpl @@ -0,0 +1,13 @@ +{{- define "push.name" -}}{{ .Release.Name }}-buzz-push-gateway{{- end }} +{{- define "push.labels" -}} +app.kubernetes.io/name: buzz-push-gateway +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} +{{- define "push.runtimeLabels" -}} +{{ include "push.labels" . }} +app.kubernetes.io/component: runtime +{{- end }} +{{- define "push.migrationLabels" -}} +{{ include "push.labels" . }} +app.kubernetes.io/component: migration +{{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml new file mode 100644 index 0000000000..38f69dee6d --- /dev/null +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "push.name" . }} + labels: {{- include "push.runtimeLabels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: { type: RollingUpdate, rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } } + selector: + matchLabels: {{- include "push.runtimeLabels" . | nindent 6 }} + template: + metadata: + labels: {{- include "push.runtimeLabels" . | nindent 8 }} + spec: + automountServiceAccountToken: false + terminationGracePeriodSeconds: 60 + securityContext: { runAsNonRoot: true, runAsUser: 65532, runAsGroup: 65532, fsGroup: 65532, seccompProfile: { type: RuntimeDefault } } + {{- with .Values.image.pullSecrets }} + imagePullSecrets: {{ toYaml . | nindent 8 }} + {{- end }} + containers: + - name: gateway + image: "{{ .Values.image.repository }}{{ if .Values.image.digest }}@{{ .Values.image.digest }}{{ else }}:{{ required "image.tag or image.digest is required" .Values.image.tag }}{{ end }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: [ALL] } } + ports: + - { name: public, containerPort: 8080 } + - { name: health, containerPort: 8081 } + env: + - { name: BUZZ_PUSH_BIND_ADDR, value: "0.0.0.0:8080" } + - { name: BUZZ_PUSH_HEALTH_ADDR, value: "0.0.0.0:8081" } + - { name: BUZZ_PUSH_PUBLIC_DELIVERY_URL, value: {{ .Values.publicDeliveryUrl | quote }} } + - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | quote }} } + - { name: BUZZ_PUSH_ENABLED_PROFILES, value: {{ .Values.enabledProfiles | quote }} } + - { name: BUZZ_PUSH_APP_ATTEST_APP_ID, value: {{ .Values.appAttestAppId | quote }} } + - { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem } + - { name: BUZZ_PUSH_APNS_KEY_PATH, value: /run/buzz/apns/provider.p8 } + {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_APNS_KEY_ID" "BUZZ_PUSH_APNS_TEAM_ID" "BUZZ_PUSH_APNS_TOPIC" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} + - name: {{ $name }} + valueFrom: { secretKeyRef: { name: {{ $.Values.existingSecret }}, key: {{ $name }} } } + {{- end }} + volumeMounts: + - { name: app-attest-root, mountPath: /run/buzz/app-attest, readOnly: true } + - { name: apns-key, mountPath: /run/buzz/apns, readOnly: true } + livenessProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 } + readinessProbe: { httpGet: { path: /_readiness, port: health }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 3 } + startupProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 2, failureThreshold: 60 } + resources: {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: app-attest-root + secret: { secretName: {{ .Values.appAttestRoot.secretName }}, items: [{ key: {{ .Values.appAttestRoot.secretKey }}, path: root.pem }] } + - name: apns-key + secret: { secretName: {{ .Values.apnsKey.secretName }}, items: [{ key: {{ .Values.apnsKey.secretKey }}, path: provider.p8 }] } + {{- with .Values.nodeSelector }} + nodeSelector: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/httproute.yaml b/deploy/charts/buzz-push-gateway/templates/httproute.yaml new file mode 100644 index 0000000000..88f4d89dbc --- /dev/null +++ b/deploy/charts/buzz-push-gateway/templates/httproute.yaml @@ -0,0 +1,14 @@ +{{- if .Values.httpRoute.enabled }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "push.name" . }} +spec: + parentRefs: {{- toYaml .Values.httpRoute.parentRefs | nindent 4 }} + hostnames: {{- toYaml .Values.httpRoute.hostnames | nindent 4 }} + rules: + - matches: + - path: { type: PathPrefix, value: / } + backendRefs: + - { name: {{ include "push.name" . }}, port: {{ .Values.service.port }} } +{{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/migration-job.yaml b/deploy/charts/buzz-push-gateway/templates/migration-job.yaml new file mode 100644 index 0000000000..d9d01c6048 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/templates/migration-job.yaml @@ -0,0 +1,33 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "push.name" . }}-migrate + labels: {{- include "push.migrationLabels" . | nindent 4 }} + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-5" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 3 + template: + metadata: + labels: {{- include "push.migrationLabels" . | nindent 8 }} + spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: { runAsNonRoot: true, runAsUser: 65532, runAsGroup: 65532, fsGroup: 65532, seccompProfile: { type: RuntimeDefault } } + {{- with .Values.image.pullSecrets }} + imagePullSecrets: {{ toYaml . | nindent 8 }} + {{- end }} + containers: + - name: migrate + image: "{{ .Values.image.repository }}{{ if .Values.image.digest }}@{{ .Values.image.digest }}{{ else }}:{{ required "image.tag or image.digest is required" .Values.image.tag }}{{ end }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: ["--migrate-only"] + securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: [ALL] } } + env: + - name: BUZZ_PUSH_RUNTIME_DATABASE_ROLE + value: {{ .Values.migration.runtimeDatabaseRole | quote }} + - name: DATABASE_URL + valueFrom: { secretKeyRef: { name: {{ .Values.migration.existingSecret }}, key: {{ .Values.migration.databaseUrlKey }} } } + resources: {{- toYaml .Values.migration.resources | nindent 12 }} diff --git a/deploy/charts/buzz-push-gateway/templates/migration-networkpolicy.yaml b/deploy/charts/buzz-push-gateway/templates/migration-networkpolicy.yaml new file mode 100644 index 0000000000..bd9661d4c6 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/templates/migration-networkpolicy.yaml @@ -0,0 +1,28 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "push.name" . }}-migration + labels: {{- include "push.migrationLabels" . | nindent 4 }} + annotations: + # Hooks precede ordinary manifests. Keep this policy alive until the next + # release's before-hook-creation cleanup so it covers the later Job hook. + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation +spec: + podSelector: + matchLabels: {{- include "push.migrationLabels" . | nindent 6 }} + policyTypes: [Ingress, Egress] + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: {{- toYaml .Values.networkPolicy.dns.namespaceSelector | nindent 14 }} + podSelector: + matchLabels: {{- toYaml .Values.networkPolicy.dns.podSelector | nindent 14 }} + ports: [{ port: 53, protocol: UDP }, { port: 53, protocol: TCP }] + - to: + {{- range .Values.networkPolicy.postgresEgressCidrs }} + - ipBlock: { cidr: {{ . }} } + {{- end }} + ports: [{ port: 5432, protocol: TCP }] diff --git a/deploy/charts/buzz-push-gateway/templates/networkpolicy.yaml b/deploy/charts/buzz-push-gateway/templates/networkpolicy.yaml new file mode 100644 index 0000000000..f7132b0580 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/templates/networkpolicy.yaml @@ -0,0 +1,42 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "push.name" . }} +spec: + podSelector: + matchLabels: {{- include "push.runtimeLabels" . | nindent 6 }} + policyTypes: [Ingress, Egress] + ingress: + - ports: [{ port: 8080, protocol: TCP }] + {{- if .Values.networkPolicy.monitoring.enabled }} + {{- if or (not .Values.networkPolicy.monitoring.namespaceSelector) (not .Values.networkPolicy.monitoring.podSelector) }} + {{- fail "networkPolicy.monitoring.enabled requires non-empty namespaceSelector and podSelector so 8081 scrape ingress is scoped, never blanket" }} + {{- end }} + # Scoped scrape access to the private health/metrics port. Off by default so + # 8081 has no pod ingress; when enabled the operator names their scraper. + - from: + - namespaceSelector: + matchLabels: {{- toYaml .Values.networkPolicy.monitoring.namespaceSelector | nindent 14 }} + podSelector: + matchLabels: {{- toYaml .Values.networkPolicy.monitoring.podSelector | nindent 14 }} + ports: [{ port: 8081, protocol: TCP }] + {{- end }} + egress: + - to: + - namespaceSelector: + matchLabels: {{- toYaml .Values.networkPolicy.dns.namespaceSelector | nindent 14 }} + podSelector: + matchLabels: {{- toYaml .Values.networkPolicy.dns.podSelector | nindent 14 }} + ports: [{ port: 53, protocol: UDP }, { port: 53, protocol: TCP }] + - to: + {{- range .Values.networkPolicy.apnsEgressCidrs }} + - ipBlock: { cidr: {{ . }} } + {{- end }} + ports: [{ port: 443, protocol: TCP }] + - to: + {{- range .Values.networkPolicy.postgresEgressCidrs }} + - ipBlock: { cidr: {{ . }} } + {{- end }} + ports: [{ port: 5432, protocol: TCP }] +{{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/pdb.yaml b/deploy/charts/buzz-push-gateway/templates/pdb.yaml new file mode 100644 index 0000000000..621586d4bf --- /dev/null +++ b/deploy/charts/buzz-push-gateway/templates/pdb.yaml @@ -0,0 +1,10 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "push.name" . }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: {{- include "push.runtimeLabels" . | nindent 6 }} +{{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/podmonitor.yaml b/deploy/charts/buzz-push-gateway/templates/podmonitor.yaml new file mode 100644 index 0000000000..ccef39c777 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/templates/podmonitor.yaml @@ -0,0 +1,19 @@ +{{- if .Values.podMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: {{ include "push.name" . }} + labels: {{- include "push.runtimeLabels" . | nindent 4 }} + {{- with .Values.podMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: {{- include "push.runtimeLabels" . | nindent 6 }} + podMetricsEndpoints: + # Scrape the private health port only; /metrics is never on the public Service. + - port: health + path: /metrics + interval: {{ .Values.podMonitor.interval }} + scrapeTimeout: {{ .Values.podMonitor.scrapeTimeout }} +{{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml b/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml new file mode 100644 index 0000000000..20b9894280 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml @@ -0,0 +1,89 @@ +{{- if .Values.prometheusRule.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: {{ include "push.name" . }} + labels: {{- include "push.labels" . | nindent 4 }} + {{- with .Values.prometheusRule.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + groups: + - name: buzz-push-gateway + rules: + # Sustained configuration faults mean the provider credential/topic is + # unhealthy; no endpoint is being invalidated but nothing is delivering. + - alert: PushGatewayConfigurationFault + expr: | + sum(rate(push_gateway_apns_deliveries_total{outcome="configuration_fault"}[5m])) > 0 + for: 10m + labels: { severity: critical } + annotations: + summary: Push gateway APNs configuration faults + description: >- + APNs is returning configuration faults (bad/expired provider token + or topic). Deliveries are failing without invalidating endpoints. + See runbook: check the APNs .p8 key, key id, team id, and topic. + # Authority store unavailable at admission = durable dependency is down. + - alert: PushGatewayAdmissionUnavailable + expr: | + sum(rate(push_gateway_admissions_total{result="unavailable"}[5m])) > 0 + for: 5m + labels: { severity: critical } + annotations: + summary: Push gateway authority store unavailable + description: >- + authorize_delivery is returning Unavailable — the PostgreSQL + authority store is unreachable or failing. Check DB connectivity + and the pod's postgres egress NetworkPolicy. + # Readiness failing on the authority cause = the pod will be pulled from + # rotation; alert before all replicas drop out. + - alert: PushGatewayReadinessAuthorityFailing + expr: | + sum(rate(push_gateway_readiness_failures_total{cause="authority"}[5m])) > 0 + for: 5m + labels: { severity: warning } + annotations: + summary: Push gateway readiness failing on authority + description: >- + Readiness probes are failing because the authority store check + fails. Replicas will be removed from the Service. Investigate DB + health before capacity drops below the PodDisruptionBudget. + # The retention reaper sweeps expired rows every 5m; a single transient + # failure self-heals on the next tick. Alert on repeated failure — + # at least two sweeps failing within ~30m (six ticks) — which grows the + # bounded crash-before-release window and leaks storage. + - alert: PushGatewayReaperFailing + expr: | + sum(increase(push_gateway_reaper_failures_total[30m])) >= 2 + for: 5m + labels: { severity: warning } + annotations: + summary: Push gateway retention reaper failing + description: >- + The retention reaper has failed at least twice within 30m (it runs + every 5m). Expired delivery reservations are not being swept, + growing the bounded-until-expiry window. Check DB write availability. + # High sustained fraction of retryable APNs outcomes indicates APNs + # throttling or degradation. The ratio is a true fraction over the + # window (increase = counts, not per-second rate), gated by a minimum + # sample count so a couple of retries at trivial volume cannot trip it. + - alert: PushGatewayHighApnsRetryRate + expr: | + ( + sum(increase(push_gateway_apns_deliveries_total{outcome="retry"}[10m])) + / sum(increase(push_gateway_apns_deliveries_total[10m])) + > {{ .Values.prometheusRule.apnsRetryRatioThreshold }} + ) + and + sum(increase(push_gateway_apns_deliveries_total[10m])) >= {{ .Values.prometheusRule.apnsRetryMinSamples }} + for: 15m + labels: { severity: warning } + annotations: + summary: Push gateway high APNs retry ratio + description: >- + The retryable fraction of APNs attempts over a 10m window + (429/500/503), above a minimum sample count, has exceeded the + configured threshold continuously for 15m. APNs may be throttling + or degraded; deliveries are delayed but not lost. +{{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/service.yaml b/deploy/charts/buzz-push-gateway/templates/service.yaml new file mode 100644 index 0000000000..73c08c1efa --- /dev/null +++ b/deploy/charts/buzz-push-gateway/templates/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "push.name" . }} + labels: {{- include "push.runtimeLabels" . | nindent 4 }} +spec: + selector: {{- include "push.runtimeLabels" . | nindent 4 }} + ports: + - { name: https, port: {{ .Values.service.port }}, targetPort: public } diff --git a/deploy/charts/buzz-push-gateway/tests/release-contract.sh b/deploy/charts/buzz-push-gateway/tests/release-contract.sh new file mode 100755 index 0000000000..7ae85ce34e --- /dev/null +++ b/deploy/charts/buzz-push-gateway/tests/release-contract.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 - <<'PY' +from pathlib import Path +import yaml + +auto_path = Path('.github/workflows/auto-tag-on-release-pr-merge.yml') +publish_path = Path('.github/workflows/push-gateway-helm-chart.yml') +auto_text = auto_path.read_text() +publish_text = publish_path.read_text() +# Parse first, then pin the cross-workflow strings whose agreement makes this a +# reachable lane rather than an orphan publisher. +yaml.safe_load(auto_text) +yaml.safe_load(publish_text) +for needle in ( + 'push-chart-release/*)', + 'VERSION="${BRANCH#push-chart-release/}"', + 'TAG_PREFIX="push-chart-v"', + 'DISPATCH="push-gateway-helm-chart"', + 'push-gateway-helm-chart) WORKFLOW="push-gateway-helm-chart.yml"', +): + assert needle in auto_text, f'missing auto-tag gateway chart contract: {needle}' +for needle in ( + 'tags: ["push-chart-v[0-9]*"]', + 'version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"', + 'refs/tags/push-chart-v${version}^{commit}', + 'deploy/charts/buzz-push-gateway', +): + assert needle in publish_text, f'missing gateway chart publisher contract: {needle}' +PY diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh new file mode 100755 index 0000000000..137f8d0add --- /dev/null +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail +out=$(mktemp); production_out=$(mktemp) +trap 'rm -f "$out" "$production_out"' EXIT + +# Defaults must lint and render without parameter injection. +helm lint deploy/charts/buzz-push-gateway >/dev/null +helm template push deploy/charts/buzz-push-gateway >"$out" +# Production values must attach push.buzz.xyz to an explicit Gateway. +production_args=( + -f deploy/charts/buzz-push-gateway/values-production.yaml + --set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + --set 'appAttestAppId=REALTEAM.xyz.buzz' + --set 'httpRoute.parentRefs[0].name=production-gateway' + --set 'httpRoute.parentRefs[0].namespace=gateway-system' + --set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16' +) +helm lint deploy/charts/buzz-push-gateway "${production_args[@]}" >/dev/null +helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >"$production_out" + +python3 - "$out" "$production_out" <<'PY' +import sys,yaml +xs=list(yaml.safe_load_all(open(sys.argv[1]))) +svc=next(x for x in xs if x and x.get('kind')=='Service') +assert [p['targetPort'] for p in svc['spec']['ports']]==['public'] +d=next(x for x in xs if x and x.get('kind')=='Deployment') +j=next(x for x in xs if x and x.get('kind')=='Job') +runtime={'app.kubernetes.io/name':'buzz-push-gateway','app.kubernetes.io/instance':'push','app.kubernetes.io/component':'runtime'} +migration={**runtime,'app.kubernetes.io/component':'migration'} +assert svc['spec']['selector']==runtime +assert d['spec']['selector']['matchLabels']==runtime +assert d['spec']['template']['metadata']['labels']==runtime +assert j['spec']['template']['metadata']['labels']==migration +assert svc['spec']['selector'] != j['spec']['template']['metadata']['labels'] +jenv={e['name']:e for e in j['spec']['template']['spec']['containers'][0]['env']} +assert jenv['BUZZ_PUSH_RUNTIME_DATABASE_ROLE']['value']=='buzz_push_gateway_runtime' +assert 'valueFrom' in jenv['DATABASE_URL'] +assert j['spec']['template']['spec']['containers'][0]['args']==['--migrate-only'] +assert j['metadata']['annotations']=={ + 'helm.sh/hook':'pre-install,pre-upgrade', + 'helm.sh/hook-weight':'-5', + 'helm.sh/hook-delete-policy':'before-hook-creation,hook-succeeded', +} +env={e['name'] for e in d['spec']['template']['spec']['containers'][0]['env']} +required={'DATABASE_URL','BUZZ_PUSH_APNS_KEY_ID','BUZZ_PUSH_APNS_TEAM_ID','BUZZ_PUSH_APNS_TOPIC','BUZZ_PUSH_GRANT_KEYS','BUZZ_PUSH_TOKEN_KEYS','BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS'} +assert required <= env +assert d['spec']['replicas'] >= 2 +assert not any(x and x.get('kind')=='HTTPRoute' for x in xs) +# Observability is opt-in: default render exposes no scrape CRDs and 8081 stays +# free of pod ingress (only 8080 is reachable). +assert not any(x and x.get('kind') in ('PodMonitor','PrometheusRule') for x in xs) +nps=[x for x in xs if x and x.get('kind')=='NetworkPolicy'] +np=next(x for x in nps if x['metadata']['name']=='push-buzz-push-gateway') +migration_np=next(x for x in nps if x['metadata']['name']=='push-buzz-push-gateway-migration') +assert np['spec']['podSelector']['matchLabels']==runtime +assert migration_np['spec']['podSelector']['matchLabels']==migration +assert migration_np['metadata']['annotations']=={ + 'helm.sh/hook':'pre-install,pre-upgrade', + 'helm.sh/hook-weight':'-10', + 'helm.sh/hook-delete-policy':'before-hook-creation', +} +assert int(migration_np['metadata']['annotations']['helm.sh/hook-weight']) < int(j['metadata']['annotations']['helm.sh/hook-weight']) +assert migration_np['spec']['ingress']==[] +assert migration_np['spec']['policyTypes']==['Ingress','Egress'] +migration_ports={p['port'] for rule in migration_np['spec']['egress'] for p in rule.get('ports',[])} +assert migration_ports=={53,5432}, migration_ports +assert all(p['port'] != 443 for rule in migration_np['spec']['egress'] for p in rule.get('ports',[])) +ingress_ports={p['port'] for rule in np['spec']['ingress'] for p in rule.get('ports',[])} +assert ingress_ports=={8080}, ingress_ports +production=list(yaml.safe_load_all(open(sys.argv[2]))) +route=next(x for x in production if x and x.get('kind')=='HTTPRoute') +assert route['spec']['parentRefs'] +assert 'push.buzz.xyz' in route['spec']['hostnames'] +PY + +# Enabling a route without a Gateway attachment must fail schema validation. +if helm template push deploy/charts/buzz-push-gateway --set httpRoute.enabled=true >/dev/null 2>&1; then + echo 'expected httpRoute.enabled=true without parentRefs to fail' >&2 + exit 1 +fi + +# The checked-in production contract is intentionally undeployable until CI or +# the release system supplies an immutable digest and environment-owned values. +if helm template push deploy/charts/buzz-push-gateway -f deploy/charts/buzz-push-gateway/values-production.yaml >/dev/null 2>&1; then + echo 'expected uninjected production values to fail' >&2 + exit 1 +fi + +# Enabling observability renders the scrape CRDs and adds a scoped 8081 ingress +# keyed to the named monitoring source — never a blanket 8081 rule. +monitoring_out=$(mktemp); trap 'rm -f "$out" "$production_out" "$monitoring_out"' EXIT +helm template push deploy/charts/buzz-push-gateway \ + --set podMonitor.enabled=true \ + --set prometheusRule.enabled=true \ + --set networkPolicy.monitoring.enabled=true \ + --set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \ + --set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \ + >"$monitoring_out" + +python3 - "$monitoring_out" <<'PY' +import sys,yaml +xs=list(yaml.safe_load_all(open(sys.argv[1]))) +pm=next(x for x in xs if x and x.get('kind')=='PodMonitor') +ep=pm['spec']['podMetricsEndpoints'][0] +assert ep['port']=='health' and ep['path']=='/metrics', ep +assert next(x for x in xs if x and x.get('kind')=='PrometheusRule')['spec']['groups'] +np=next(x for x in xs if x and x.get('kind')=='NetworkPolicy' and x['metadata']['name']=='push-buzz-push-gateway') +mon=[r for r in np['spec']['ingress'] if {p['port'] for p in r.get('ports',[])}=={8081}] +assert len(mon)==1, 'exactly one scoped 8081 ingress rule' +frm=mon[0]['from'][0] +# 8081 ingress must be scoped by both selectors, never empty/blanket. +assert frm['namespaceSelector']['matchLabels'] and frm['podSelector']['matchLabels'], frm +PY + +# Negative: monitoring enabled with default empty selectors must fail (would +# otherwise render a blanket 8081 rule matching all namespaces/pods). +if helm template push deploy/charts/buzz-push-gateway \ + --set podMonitor.enabled=true \ + --set networkPolicy.monitoring.enabled=true >/dev/null 2>&1; then + echo 'expected monitoring.enabled with empty selectors to fail' >&2 + exit 1 +fi + +# Negative: scrape flags must be coupled. PodMonitor without ingress = an +# unreachable scraper; ingress without a PodMonitor = an open hole with no +# scraper. Both mismatches must fail schema validation. +if helm template push deploy/charts/buzz-push-gateway \ + --set podMonitor.enabled=true \ + --set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \ + --set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \ + >/dev/null 2>&1; then + echo 'expected podMonitor.enabled without monitoring ingress to fail' >&2 + exit 1 +fi +if helm template push deploy/charts/buzz-push-gateway \ + --set networkPolicy.monitoring.enabled=true \ + --set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \ + --set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \ + >/dev/null 2>&1; then + echo 'expected monitoring ingress without podMonitor.enabled to fail' >&2 + exit 1 +fi + +# Negative: retry-ratio threshold is a fraction; a value > 1 must fail schema. +if helm template push deploy/charts/buzz-push-gateway \ + --set prometheusRule.enabled=true \ + --set prometheusRule.apnsRetryRatioThreshold=2 >/dev/null 2>&1; then + echo 'expected apnsRetryRatioThreshold=2 to fail' >&2 + exit 1 +fi diff --git a/deploy/charts/buzz-push-gateway/values-production.yaml b/deploy/charts/buzz-push-gateway/values-production.yaml new file mode 100644 index 0000000000..7a0569616f --- /dev/null +++ b/deploy/charts/buzz-push-gateway/values-production.yaml @@ -0,0 +1,15 @@ +# Required environment-owned values are deliberately invalid/empty here. A +# production renderer must inject all of them; CI proves omission fails. +image: + tag: "" + digest: "" +appAttestAppId: "" +httpRoute: + enabled: true + parentRefs: [] + hostnames: + - push.buzz.xyz +networkPolicy: + apnsEgressCidrs: + - 0.0.0.0/0 + postgresEgressCidrs: [] diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json new file mode 100644 index 0000000000..631a04aea5 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -0,0 +1,334 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "replicaCount": { + "type": "integer", + "minimum": 2 + }, + "existingSecret": { + "type": "string", + "minLength": 1 + }, + "publicDeliveryUrl": { + "const": "https://push.buzz.xyz/v1/deliveries/apns" + }, + "maxGrantLifetimeSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 31536000 + }, + "appAttestAppId": { + "type": "string", + "minLength": 1 + }, + "httpRoute": { + "type": "object", + "required": [ + "enabled", + "parentRefs", + "hostnames" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "parentRefs": { + "type": "array" + }, + "hostnames": { + "type": "array", + "contains": { + "const": "push.buzz.xyz" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + }, + "then": { + "properties": { + "parentRefs": { + "minItems": 1 + } + } + } + } + ] + }, + "networkPolicy": { + "type": "object", + "required": [ + "enabled", + "apnsEgressCidrs", + "postgresEgressCidrs", + "dns" + ], + "properties": { + "enabled": { + "const": true + }, + "apnsEgressCidrs": { + "type": "array", + "minItems": 1 + }, + "postgresEgressCidrs": { + "type": "array", + "minItems": 1 + }, + "dns": { + "type": "object", + "required": [ + "namespaceSelector", + "podSelector" + ] + }, + "monitoring": { + "type": "object", + "required": [ + "enabled", + "namespaceSelector", + "podSelector" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "namespaceSelector": { + "type": "object" + }, + "podSelector": { + "type": "object" + } + }, + "allOf": [ + { + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + }, + "then": { + "properties": { + "namespaceSelector": { + "minProperties": 1 + }, + "podSelector": { + "minProperties": 1 + } + } + } + } + ] + } + } + }, + "podMonitor": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "prometheusRule": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "apnsRetryRatioThreshold": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1 + }, + "apnsRetryMinSamples": { + "type": "integer", + "minimum": 1 + } + } + }, + "image": { + "type": "object", + "required": [ + "repository" + ], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": "string" + }, + "digest": { + "type": "string", + "pattern": "^$|^sha256:[0-9a-f]{64}$" + } + }, + "anyOf": [ + { + "properties": { + "tag": { + "minLength": 1 + } + } + }, + { + "properties": { + "digest": { + "pattern": "^sha256:[0-9a-f]{64}$" + } + } + } + ] + }, + "migration": { + "type": "object", + "additionalProperties": false, + "required": [ + "existingSecret", + "databaseUrlKey", + "runtimeDatabaseRole", + "resources" + ], + "properties": { + "existingSecret": { + "type": "string", + "minLength": 1 + }, + "databaseUrlKey": { + "type": "string", + "minLength": 1 + }, + "runtimeDatabaseRole": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]{0,62}$" + }, + "resources": { + "type": "object" + } + } + } + }, + "required": [ + "replicaCount", + "existingSecret", + "publicDeliveryUrl", + "maxGrantLifetimeSeconds", + "appAttestAppId", + "httpRoute", + "image", + "migration" + ], + "allOf": [ + { + "$comment": "Scraping opt-in is coupled: a PodMonitor and its scoped 8081 ingress must be enabled together, so we never render a scraper that cannot reach the port nor an ingress hole with no scraper.", + "if": { + "properties": { + "podMonitor": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + } + }, + "required": [ + "podMonitor" + ] + }, + "then": { + "properties": { + "networkPolicy": { + "properties": { + "monitoring": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + } + }, + "required": [ + "monitoring" + ] + } + }, + "required": [ + "networkPolicy" + ] + } + }, + { + "if": { + "properties": { + "networkPolicy": { + "properties": { + "monitoring": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + } + }, + "required": [ + "monitoring" + ] + } + }, + "required": [ + "networkPolicy" + ] + }, + "then": { + "properties": { + "podMonitor": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + } + }, + "required": [ + "podMonitor" + ] + } + } + ] +} diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml new file mode 100644 index 0000000000..ec46d9dbdd --- /dev/null +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -0,0 +1,92 @@ +replicaCount: 2 +image: + repository: ghcr.io/block/buzz-push-gateway + # `main` is published by the push-gateway lane on every main push. + tag: main + digest: "" + pullPolicy: IfNotPresent + pullSecrets: [] +existingSecret: buzz-push-gateway +# DDL-capable credentials are used only by the pre-install/pre-upgrade migration +# Job. Runtime DATABASE_URL in existingSecret should have DML-only privileges. +migration: + existingSecret: buzz-push-gateway-migrations + databaseUrlKey: DATABASE_URL + # Existing LOGIN role used by runtime DATABASE_URL. Migrations grant it only + # CONNECT plus DML on the six gateway tables in this dedicated database. + runtimeDatabaseRole: buzz_push_gateway_runtime + resources: + requests: {cpu: 50m, memory: 64Mi} + limits: {cpu: 250m, memory: 128Mi} +publicDeliveryUrl: https://push.buzz.xyz/v1/deliveries/apns +maxGrantLifetimeSeconds: 2592000 +enabledProfiles: buzz-ios-production +# Example App Attest identifier. Production MUST override this with the exact +# Apple TEAMID.bundle-id value (see values-production.yaml). +appAttestAppId: TEAMID.xyz.buzz +appAttestRoot: + secretName: buzz-push-gateway + secretKey: app-attest-root.pem +apnsKey: + secretName: buzz-push-gateway + secretKey: apns-provider.p8 +service: + port: 8080 +httpRoute: + # Disabled by default so a generic install cannot claim an unattached route. + # Production enables this with an explicit Gateway parentRef. + enabled: false + parentRefs: [] + hostnames: [push.buzz.xyz] +resources: + requests: {cpu: 100m, memory: 128Mi} + limits: {cpu: "1", memory: 512Mi} +podDisruptionBudget: + enabled: true + minAvailable: 1 +networkPolicy: + enabled: true + # Kubernetes NetworkPolicy cannot allow DNS names. Production operators must + # narrow these CIDRs to their PostgreSQL/NAT destinations where supported. + apnsEgressCidrs: [0.0.0.0/0] + # Override with the actual database network. This example private range is + # intentionally separate from broad APNs HTTPS egress. + postgresEgressCidrs: [10.0.0.0/8] + dns: + namespaceSelector: + kubernetes.io/metadata.name: kube-system + podSelector: + k8s-app: kube-dns + # Scoped ingress to the private metrics port (8081). Off by default so 8081 + # has no pod ingress at all; enable only alongside podMonitor and name the + # scraper's namespace/pod so reachability stays narrow. + monitoring: + enabled: false + namespaceSelector: {} + podSelector: {} +# Prometheus-operator PodMonitor scraping the private /metrics on port 8081. +# Off by default; requires networkPolicy.monitoring to also be enabled. +podMonitor: + enabled: false + interval: 30s + scrapeTimeout: 10s + labels: {} +# Prometheus-operator alerting rules. Off by default. +prometheusRule: + enabled: false + labels: {} + # Retryable-outcome fraction (0..1] that fires PushGatewayHighApnsRetryRate. + apnsRetryRatioThreshold: 0.25 + # Minimum APNs attempts in the 10m window before the retry-ratio alert can + # fire, so a couple of retries at trivial volume cannot trip it. + apnsRetryMinSamples: 20 +nodeSelector: {} +tolerations: [] +affinity: {} +topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: buzz-push-gateway diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index b3ad26a5db..68743d8505 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,7 +7,7 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.2 +version: 0.1.3 appVersion: "0.1.0" home: https://github.com/block/buzz sources: diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 010b3dffde..81ab7ca096 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -52,6 +52,22 @@ See: The chart fails at `helm install` / `helm template` time with a clear message if any of these are missing or malformed (see `templates/_validate.tpl`). +## Device pairing relay + +The chart can run Buzz's stateless pairing WebSocket relay as an independent +Deployment and Service using the same image as the main relay: + +```yaml +pairingRelay: + enabled: true + url: wss://pairing.example.com +``` + +`pairingRelay.url` is advertised in the main relay's NIP-11 document so Buzz +clients connect directly to the dedicated endpoint. The chart does not create +an Ingress or HTTPRoute for the pairing Service; route the public hostname to +`-buzz-pairing:5000` with your platform's ingress configuration. + ## HA (production) `replicaCount > 1` hard-requires Redis: diff --git a/deploy/charts/buzz/templates/NOTES.txt b/deploy/charts/buzz/templates/NOTES.txt index bf333c7f81..d65c1f837b 100644 --- a/deploy/charts/buzz/templates/NOTES.txt +++ b/deploy/charts/buzz/templates/NOTES.txt @@ -30,7 +30,7 @@ ⚠ QUICKSTART / EVALUATION PROFILE {{ if .Values.postgresql.enabled }}- In-cluster Postgres subchart (CloudPirates){{ end }} {{ if .Values.redis.enabled }}- In-cluster Redis subchart (CloudPirates){{ end }} - {{ if .Values.minio.enabled }}- In-cluster MinIO (eval-only, single replica; bucket "{{ .Values.s3.bucket }}" created by post-install Job){{ end }} + {{ if .Values.minio.enabled }}- In-cluster MinIO (eval-only, single replica; bucket "{{ .Values.s3.bucket }}" created by a bundled init Job){{ end }} - Chart auto-generates secrets via the `lookup` pattern. This is NOT GitOps-safe — secrets will silently rotate under ArgoCD/Flux. For production, see examples/argocd-app.yaml or examples/flux-helmrelease.yaml. @@ -62,6 +62,10 @@ {{- if not .Values.relay.requireRelayMembership }} ⚠ relay.requireRelayMembership=false — relay is OPEN. Anyone can publish. {{- end }} +{{- if not .Values.relay.requireMediaGetAuth }} + ⚠ relay.requireMediaGetAuth=false — media GET/HEAD reads are not auth-gated. + Keep this false only during staged client rollout. +{{- end }} {{- if not .Values.migrate.autoMigrate }} ⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations. You must run `buzz-admin migrate` against the database before every diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl index ccbb8090f9..6e3f52a580 100644 --- a/deploy/charts/buzz/templates/_helpers.tpl +++ b/deploy/charts/buzz/templates/_helpers.tpl @@ -121,3 +121,8 @@ secrets.existingSecret, use that. Otherwise use the chart-managed one. {{- include "buzz.minioEndpoint" . -}} {{- end -}} {{- end -}} + +{{- define "buzz.pairingRelaySelectorLabels" -}} +{{ include "buzz.selectorLabels" . }} +app.kubernetes.io/component: pairing-relay +{{- end -}} diff --git a/deploy/charts/buzz/templates/_validate.tpl b/deploy/charts/buzz/templates/_validate.tpl index 6c99fdda92..7ca5fe29af 100644 --- a/deploy/charts/buzz/templates/_validate.tpl +++ b/deploy/charts/buzz/templates/_validate.tpl @@ -46,6 +46,11 @@ surface at template time regardless of which manifest helm renders first. {{- end -}} {{- end -}} +{{/* Pairing relay deployment must have an advertised public URL. */}} +{{- if and .Values.pairingRelay.enabled (not .Values.pairingRelay.url) -}} + {{- fail "pairingRelay.url is required when pairingRelay.enabled=true" -}} +{{- end -}} + {{/* ingress + httproute mutually exclusive */}} {{- if and .Values.ingress.enabled .Values.httproute.enabled -}} {{- fail "ingress.enabled and httproute.enabled cannot both be true — choose one." -}} diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 9f334197b6..10ebd3b7a6 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -53,6 +53,41 @@ spec: topologySpreadConstraints: {{- toYaml . | nindent 8 }} {{- end }} + {{- if .Values.minio.enabled }} + # Quickstart only: the bundled MinIO bucket is created by a concurrent + # init Job (templates/quickstart-minio-init.yaml). The relay's A3 S3 + # conformance probe is startup-fatal, so without this gate the relay Pods + # CrashLoopBackOff (with growing backoff) until the bucket appears. Block + # relay start until the bucket exists — deterministic, no crash-loops. + initContainers: + - name: wait-for-bucket + image: {{ .Values.minio.mcImage | quote }} + securityContext: + {{- toYaml .Values.relay.containerSecurityContext | nindent 12 }} + env: + - name: S3_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.chartSecretName" . }} + key: BUZZ_S3_ACCESS_KEY + - name: S3_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.chartSecretName" . }} + key: BUZZ_S3_SECRET_KEY + command: ["/bin/sh", "-c"] + args: + - | + set -e + export MC_CONFIG_DIR=/tmp/.mc + until mc alias set local {{ include "buzz.minioEndpoint" . }} "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null 2>&1; do + echo "waiting for MinIO..."; sleep 2 + done + until mc stat local/{{ .Values.s3.bucket }} >/dev/null 2>&1; do + echo "waiting for bucket {{ .Values.s3.bucket }}..."; sleep 2 + done + echo "bucket {{ .Values.s3.bucket }} present" + {{- end }} containers: - name: relay image: {{ include "buzz.image" . }} @@ -69,6 +104,9 @@ spec: - { name: BUZZ_HEALTH_PORT, value: {{ .Values.service.healthPort | quote }} } - { name: BUZZ_METRICS_PORT, value: {{ .Values.service.metricsPort | quote }} } - { name: RELAY_URL, value: {{ .Values.relayUrl | quote }} } + {{- if .Values.pairingRelay.url }} + - { name: BUZZ_PAIRING_RELAY_URL, value: {{ .Values.pairingRelay.url | quote }} } + {{- end }} - { name: BUZZ_MEDIA_BASE_URL, value: {{ include "buzz.mediaBaseUrl" . | quote }} } # ── Behavior ───────────────────────────────────────────── @@ -77,6 +115,7 @@ spec: - { name: BUZZ_SEND_BUFFER, value: {{ .Values.relay.sendBuffer | quote }} } - { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} } - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} } + - { name: BUZZ_REQUIRE_MEDIA_GET_AUTH, value: {{ .Values.relay.requireMediaGetAuth | quote }} } - { name: BUZZ_ALLOW_NIP_OA_AUTH, value: {{ .Values.relay.allowNipOaAuth | quote }} } - { name: BUZZ_PUBKEY_ALLOWLIST, value: {{ .Values.relay.pubkeyAllowlist | quote }} } {{- if .Values.relay.corsOrigins }} @@ -85,6 +124,15 @@ spec: {{- if gt (.Values.relay.ephemeralTtlOverride | int) 0 }} - { name: BUZZ_EPHEMERAL_TTL_OVERRIDE, value: {{ .Values.relay.ephemeralTtlOverride | quote }} } {{- end }} + {{- if .Values.relay.uploadRecords }} + - { name: BUZZ_MEDIA_UPLOAD_RECORDS, value: "true" } + {{- end }} + {{- if .Values.relay.uploadIpHeader }} + - { name: BUZZ_MEDIA_UPLOAD_IP_HEADER, value: {{ .Values.relay.uploadIpHeader | quote }} } + {{- end }} + {{- if .Values.relay.uploadPortHeader }} + - { name: BUZZ_MEDIA_UPLOAD_PORT_HEADER, value: {{ .Values.relay.uploadPortHeader | quote }} } + {{- end }} # ── Owner ──────────────────────────────────────────────── - { name: RELAY_OWNER_PUBKEY, value: {{ .Values.ownerPubkey | quote }} } @@ -172,5 +220,6 @@ spec: persistentVolumeClaim: claimName: {{ default (printf "%s-git" (include "buzz.fullname" .)) .Values.persistence.git.existingClaim }} {{- else }} - emptyDir: {} + emptyDir: + sizeLimit: {{ .Values.persistence.git.size | quote }} {{- end }} diff --git a/deploy/charts/buzz/templates/pairing-relay.yaml b/deploy/charts/buzz/templates/pairing-relay.yaml new file mode 100644 index 0000000000..86e362fe03 --- /dev/null +++ b/deploy/charts/buzz/templates/pairing-relay.yaml @@ -0,0 +1,72 @@ +{{- include "buzz.validate" . -}} +{{- if .Values.pairingRelay.enabled -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "buzz.fullname" . }}-pairing + labels: + {{- include "buzz.labels" . | nindent 4 }} + app.kubernetes.io/component: pairing-relay +spec: + replicas: {{ .Values.pairingRelay.replicaCount }} + selector: + matchLabels: + {{- include "buzz.pairingRelaySelectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "buzz.pairingRelaySelectorLabels" . | nindent 8 }} + {{- with .Values.pairingRelay.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.pairingRelay.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + securityContext: + {{- toYaml .Values.relay.securityContext | nindent 8 }} + containers: + - name: pairing-relay + image: {{ include "buzz.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["/usr/local/bin/buzz-pair-relay"] + securityContext: + {{- toYaml .Values.relay.containerSecurityContext | nindent 12 }} + env: + - name: BUZZ_PAIR_RELAY_BIND_ADDR + value: "0.0.0.0:{{ .Values.pairingRelay.service.port }}" + ports: + - name: websocket + containerPort: {{ .Values.pairingRelay.service.port }} + protocol: TCP + readinessProbe: + tcpSocket: + port: websocket + livenessProbe: + tcpSocket: + port: websocket + resources: + {{- toYaml .Values.pairingRelay.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "buzz.fullname" . }}-pairing + labels: + {{- include "buzz.labels" . | nindent 4 }} + app.kubernetes.io/component: pairing-relay + {{- with .Values.pairingRelay.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.pairingRelay.service.type }} + selector: + {{- include "buzz.pairingRelaySelectorLabels" . | nindent 4 }} + ports: + - name: websocket + port: {{ .Values.pairingRelay.service.port }} + targetPort: websocket + protocol: TCP +{{- end }} diff --git a/deploy/charts/buzz/templates/quickstart-minio-init.yaml b/deploy/charts/buzz/templates/quickstart-minio-init.yaml index 044f4c5843..f1c0027880 100644 --- a/deploy/charts/buzz/templates/quickstart-minio-init.yaml +++ b/deploy/charts/buzz/templates/quickstart-minio-init.yaml @@ -1,22 +1,35 @@ {{- /* -Creates the media bucket in the bundled MinIO after install/upgrade. Mirrors -the docker-compose `minio-init` step. Hook-managed so it re-runs on upgrade -and is garbage-collected. Quickstart-only. +Creates the media bucket in the bundled MinIO. Mirrors the docker-compose +`minio-init` step (which depends_on minio and runs concurrently, not as a +lifecycle hook). + +This is a NORMAL resource, deliberately NOT a helm hook. The relay's A3 +object-store conformance probe is startup-fatal and does not create the bucket +itself, so the bucket must exist for a relay Pod to pass readiness. A +`post-install` hook cannot satisfy that: `helm install --wait` blocks on the +main resources (the relay Deployment) becoming Ready *before* post-install +hooks run, but the relay can never become Ready without the bucket — a +deadlock (relays CrashLoopBackOff forever). A `pre-install` hook deadlocks the +other way: it would run to completion before MinIO (a normal resource) is +created. Running the Job as a normal resource lets helm create MinIO, this Job, +and the relay Deployment together; the Job's `until` loop waits for MinIO, and +`--wait` waits for the Job to complete alongside the Deployment. + +The name carries the release revision so `helm upgrade` creates a fresh Job +(a completed Job's spec is immutable); `ttlSecondsAfterFinished` GCs it. +Quickstart-only. */ -}} {{- if .Values.minio.enabled -}} apiVersion: batch/v1 kind: Job metadata: - name: {{ include "buzz.minioFullname" . }}-init + name: {{ include "buzz.minioFullname" . }}-init-{{ .Release.Revision }} labels: {{- include "buzz.labels" . | nindent 4 }} app.kubernetes.io/component: minio-init - annotations: - helm.sh/hook: post-install,post-upgrade - helm.sh/hook-weight: "0" - helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded spec: backoffLimit: 10 + ttlSecondsAfterFinished: 120 template: metadata: labels: diff --git a/deploy/charts/buzz/tests/pairing_relay_test.yaml b/deploy/charts/buzz/tests/pairing_relay_test.yaml new file mode 100644 index 0000000000..2ed2ef3b40 --- /dev/null +++ b/deploy/charts/buzz/tests/pairing_relay_test.yaml @@ -0,0 +1,54 @@ +suite: optional pairing relay +templates: + - templates/deployment.yaml + - templates/secret-chart.yaml + - templates/pairing-relay.yaml + - templates/serviceaccount.yaml +tests: + - it: does not render the pairing relay by default + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + asserts: + - hasDocuments: + count: 0 + template: templates/pairing-relay.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_PAIRING_RELAY_URL + template: templates/deployment.yaml + + - it: renders and advertises the configured pairing relay + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + pairingRelay.enabled: true + pairingRelay.url: wss://pairing.buzz.xyz + asserts: + - equal: + path: kind + value: Deployment + documentIndex: 0 + template: templates/pairing-relay.yaml + - equal: + path: kind + value: Service + documentIndex: 1 + template: templates/pairing-relay.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_PAIRING_RELAY_URL + value: wss://pairing.buzz.xyz + template: templates/deployment.yaml diff --git a/deploy/charts/buzz/tests/quickstart_bundled_test.yaml b/deploy/charts/buzz/tests/quickstart_bundled_test.yaml index 3a2f6ab940..ecb7877e9f 100644 --- a/deploy/charts/buzz/tests/quickstart_bundled_test.yaml +++ b/deploy/charts/buzz/tests/quickstart_bundled_test.yaml @@ -11,6 +11,7 @@ tests: - it: renders the in-cluster MinIO Deployment when minio.enabled release: name: rel + revision: 1 set: relayUrl: wss://buzz.example.com ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" @@ -24,10 +25,16 @@ tests: name: rel-buzz-minio template: templates/quickstart-minio.yaml documentIndex: 0 + # The bucket-init Job is a NORMAL resource (revision-suffixed name), not a + # helm hook: a post-install hook deadlocks against `--wait` because the + # relay's startup-fatal S3 probe can't pass until the bucket exists. - containsDocument: kind: Job apiVersion: batch/v1 - name: rel-buzz-minio-init + name: rel-buzz-minio-init-1 + template: templates/quickstart-minio-init.yaml + - notExists: + path: metadata.annotations["helm.sh/hook"] template: templates/quickstart-minio-init.yaml - it: relay S3 endpoint resolves to the bundled MinIO Service diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 08c391d1f0..6313fd5958 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -130,7 +130,8 @@ tests: path: spec.template.spec.volumes content: name: git-repos - emptyDir: {} + emptyDir: + sizeLimit: 10Gi template: templates/deployment.yaml # No PVC is rendered at all when persistence is disabled. - hasDocuments: diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index c7a878ee00..194d679293 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -61,6 +61,7 @@ "sendBuffer": { "type": "integer", "minimum": 1 }, "requireAuthToken": { "type": "boolean" }, "requireRelayMembership": { "type": "boolean" }, + "requireMediaGetAuth": { "type": "boolean" }, "allowNipOaAuth": { "type": "boolean" }, "huddleAudioAvailable": { "type": ["boolean", "null"], @@ -230,6 +231,31 @@ "labels": { "type": "object" } } }, + "pairingRelay": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "url": { + "type": "string", + "pattern": "^(wss?://.+)?$", + "description": "Public WebSocket URL advertised to clients for NIP-AB device pairing." + }, + "replicaCount": { "type": "integer", "minimum": 1 }, + "service": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "annotations": { "type": "object" } + } + }, + "podAnnotations": { "type": "object" }, + "podLabels": { "type": "object" }, + "resources": { "type": "object" } + } + }, "extraManifests": { "type": "array" } diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 81d2e7c344..9edb534f1b 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -75,6 +75,10 @@ relay: sendBuffer: 1000 requireAuthToken: true requireRelayMembership: true + # Staged rollout gate for authenticated media reads. When true, relay + # GET/HEAD /media/* requires Blossom kind 24242 t=get plus relay membership. + # Keep false until deployed desktop/mobile/CLI clients attach read auth. + requireMediaGetAuth: false allowNipOaAuth: true pubkeyAllowlist: false corsOrigins: [] @@ -84,6 +88,17 @@ relay: # accepts/owns the external multi-pod audio/SFU behavior. huddleAudioAvailable: null ephemeralTtlOverride: 0 + # Per-upload-event records (`_uploads/` moderation side channel). Off by + # default. Operators hosting communities for other people may have legal + # obligations (e.g. NCMEC reporting for US-serving providers) that require + # recording the network address of an upload; Buzz never collects IPs unless + # uploadIpHeader is set. When set (e.g. "cf-connecting-ip"), the connecting + # address reported by YOUR trusted edge is stored in the per-event record + # only — never served to clients, never in event data. uploadRecords must be + # true for uploadIpHeader to be valid (startup-checked). + uploadRecords: false + uploadIpHeader: "" + uploadPortHeader: "" livenessProbe: httpGet: @@ -139,6 +154,28 @@ relay: extraEnv: [] extraEnvFrom: [] +# ── Device pairing relay ───────────────────────────────────────────────────── +# Optional, stateless NIP-AB relay. When enabled, the main relay advertises +# pairingRelay.url in NIP-11 and Buzz clients use it instead of the legacy +# same-host /pair convention. +pairingRelay: + enabled: false + url: "" + replicaCount: 1 + service: + type: ClusterIP + port: 5000 + annotations: {} + podAnnotations: {} + podLabels: {} + resources: + requests: + cpu: "50m" + memory: "32Mi" + limits: + cpu: "250m" + memory: "128Mi" + # ── Service ────────────────────────────────────────────────────────────────── service: type: ClusterIP @@ -181,17 +218,18 @@ httproute: # enabled: true → mount a PVC at mountPath (durable across pod restarts, but a # single ReadWriteOnce PVC binds to one node, so it does NOT support multi-pod # scheduling across nodes on a Deployment). -# enabled: false → mount a per-pod emptyDir at mountPath (pure scratch). This is -# the correct choice for multi-replica HA: each pod gets its own local working -# space, nothing is shared, and there is no volume to multi-attach. Safe -# because the object store + Postgres are the sources of truth, not this disk. +# enabled: false → mount a per-pod emptyDir at mountPath (pure scratch), bounded +# by size. This is the correct choice for multi-replica HA: each pod gets its +# own local working space, nothing is shared, and there is no volume to +# multi-attach. Safe because the object store + Postgres are the sources of +# truth, not this disk. persistence: git: enabled: true mountPath: /var/lib/buzz/git storageClass: "" accessMode: ReadWriteOnce - size: 10Gi + size: 10Gi # PVC capacity or emptyDir sizeLimit annotations: {} existingClaim: "" @@ -211,7 +249,7 @@ postgresql: enabled: true size: 10Gi externalPostgresql: - url: "" # postgres://user:pass@host:5432/db + url: "" # postgres://user:pass@host:5432/db — placeholder example, sadscan:disable np.postgres.1 # ── Redis ──────────────────────────────────────────────────────────────────── # Eval-only CloudPirates subchart (standalone). REDIS_URL is composed in the diff --git a/deploy/local/.gitignore b/deploy/local/.gitignore new file mode 100644 index 0000000000..ec9fb0fcdc --- /dev/null +++ b/deploy/local/.gitignore @@ -0,0 +1,3 @@ +# Corporate-proxy CA is machine/environment-specific and often internal. +# Regenerate locally (see build-and-deploy.sh); never commit. +proxy-ca.pem diff --git a/deploy/local/build-and-deploy.sh b/deploy/local/build-and-deploy.sh new file mode 100755 index 0000000000..4dce0fdc36 --- /dev/null +++ b/deploy/local/build-and-deploy.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Local docker-desktop k8s testbed for the Buzz relay mesh. +# +# Repeatable path: build image -> helm dep build -> helm install (quickstart HA, +# 3 replicas) -> wait 3/3 Ready -> probe /_readiness on every pod. This is the +# baseline every mesh build redeploys onto (mesh lane). +# +# Prereqs: docker-desktop k8s context Ready, helm >= 3.14, kubectl, docker. +# docker-desktop shares the docker image store with k8s, so a locally-built +# tag + pullPolicy: IfNotPresent needs no registry push or `kind load`. +# +# Usage: +# deploy/local/build-and-deploy.sh # full: build + deploy +# SKIP_BUILD=1 deploy/local/build-and-deploy.sh # redeploy existing image +# IMAGE_TAG=mesh-abc1234 deploy/local/build-and-deploy.sh +set -euo pipefail + +# ── config ────────────────────────────────────────────────────────────────── +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +NS="${NS:-buzz-mesh}" +RELEASE="${RELEASE:-buzz}" +IMAGE_REPO="${IMAGE_REPO:-buzz-relay}" +IMAGE_TAG="${IMAGE_TAG:-mesh-local}" +CHART="${REPO_ROOT}/deploy/charts/buzz" +VALUES="${REPO_ROOT}/deploy/local/quickstart-ha-values.yaml" +CA_PEM="${REPO_ROOT}/deploy/local/proxy-ca.pem" +EXPECT_CTX="docker-desktop" +REPLICAS=3 +EVID="${EVID:-/tmp/mesh-build/deploy-evidence-$(date +%Y%m%d-%H%M%S)}" +mkdir -p "$EVID" + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; } + +# ── 0. guardrails ───────────────────────────────────────────────────────────── +CTX="$(kubectl config current-context)" +[ "$CTX" = "$EXPECT_CTX" ] || die "kube context is '$CTX', expected '$EXPECT_CTX' (refusing to touch a non-local cluster)" +log "context: $CTX"; kubectl get nodes | tee "$EVID/nodes.txt" + +# ── 1. corporate-proxy CA + npm mirror (TLS-intercepting gateway) ──────────── +# Two stacked blocks on Block's network: (a) the gateway re-signs TLS with +# internal CAs the build container doesn't trust; (b) public registry.npmjs.org +# is policy-blocked (Dependency Confusion mitigation), so npm/corepack must use +# the Artifactory mirror. Both no-op on a normal network (build-args stay unset). +CA_ARG=() +REG_ARG=() +# (a) Build a complete internal-CA bundle from the macOS System keychain. +if [ ! -f "$CA_PEM" ] && command -v security >/dev/null 2>&1; then + log "exporting Block internal CA bundle from System keychain" + : > "$CA_PEM" + for name in "Cloudflare Gateway CA" \ + "Service To Service AWS Native CA production G0" \ + "Corp Systems AWS Native CA production G0" \ + "Block, Inc CA G1" \ + "Square Primary Certificate Authority - G2"; do + security find-certificate -a -c "$name" -p /Library/Keychains/System.keychain >> "$CA_PEM" 2>/dev/null || true + done +fi +if [ -f "$CA_PEM" ] && grep -q 'BEGIN CERTIFICATE' "$CA_PEM"; then + CA_ARG=(--build-arg "EXTRA_CA_CERTS=deploy/local/proxy-ca.pem") + log "using proxy CA bundle ($(grep -c 'BEGIN CERTIFICATE' "$CA_PEM") certs)" +fi +# (b) Use the host's configured npm registry (Artifactory) if it isn't public. +HOST_NPM_REG="$(pnpm config get registry 2>/dev/null || echo '')" +if [ -n "$HOST_NPM_REG" ] && ! echo "$HOST_NPM_REG" | grep -q 'registry.npmjs.org'; then + REG_ARG=(--build-arg "NPM_REGISTRY=${HOST_NPM_REG}") + log "using npm registry mirror: $HOST_NPM_REG" +fi + +# ── 2. build image ──────────────────────────────────────────────────────────── +if [ "${SKIP_BUILD:-0}" != "1" ]; then + log "building ${IMAGE_REPO}:${IMAGE_TAG} (HEAD $(git -C "$REPO_ROOT" rev-parse --short HEAD))" + git -C "$REPO_ROOT" rev-parse HEAD > "$EVID/build-sha.txt" + docker build "${CA_ARG[@]}" "${REG_ARG[@]}" \ + -t "${IMAGE_REPO}:${IMAGE_TAG}" \ + -f "${REPO_ROOT}/Dockerfile" "${REPO_ROOT}" 2>&1 | tee "$EVID/build.log" +else + log "SKIP_BUILD=1 — reusing ${IMAGE_REPO}:${IMAGE_TAG}" +fi +docker image inspect "${IMAGE_REPO}:${IMAGE_TAG}" --format '{{.Id}} {{.Size}}' | tee "$EVID/image-id.txt" + +# ── 3. chart deps + install ─────────────────────────────────────────────────── +log "helm dependency build" +helm dependency build "$CHART" 2>&1 | tee "$EVID/helm-dep.txt" + +log "helm upgrade --install $RELEASE (ns=$NS, replicas=$REPLICAS)" +# No --wait here: the relay's A3 S3 probe is startup-fatal, so relays +# CrashLoopBackOff a few times until the concurrent init Job creates the bucket. +# helm --wait races that transient and can bail early; instead we own readiness +# gating below (rollout status + per-pod probe), which tolerates the restarts. +helm upgrade --install "$RELEASE" "$CHART" \ + --namespace "$NS" --create-namespace \ + --values "$VALUES" \ + --set image.repository="$IMAGE_REPO" \ + --set image.tag="$IMAGE_TAG" \ + --timeout 5m 2>&1 | tee "$EVID/helm-install.txt" +helm_rc=${PIPESTATUS[0]} +if [ "$helm_rc" != 0 ]; then + kubectl -n "$NS" get pods -o wide | tee "$EVID/pods-onfail.txt" + kubectl -n "$NS" describe pods -l app.kubernetes.io/name=buzz | tee "$EVID/describe-onfail.txt" + kubectl -n "$NS" logs -l app.kubernetes.io/name=buzz --tail=100 --all-containers | tee "$EVID/logs-onfail.txt" + die "helm install returned rc=$helm_rc" +fi + +# ── 4. verify 3/3 Ready ─────────────────────────────────────────────────────── +# Find the relay Deployment: everything under this release named "buzz" except +# the bundled "*-minio" Deployment. (The chart fullname collapses +# "-" to "" when the release name already contains the +# chart name, so the name isn't always "-buzz".) +DEPLOY="" +for d in $(kubectl -n "$NS" get deploy -l "app.kubernetes.io/instance=$RELEASE" \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'); do + case "$d" in *-minio) continue;; esac + DEPLOY="$d"; break +done +[ -n "$DEPLOY" ] || die "could not locate the relay Deployment" +log "waiting for $REPLICAS relay pods Ready (deployment: $DEPLOY)" +kubectl -n "$NS" rollout status deployment/"$DEPLOY" --timeout=4m | tee "$EVID/rollout.txt" +kubectl -n "$NS" get pods -o wide | tee "$EVID/pods.txt" + +READY=$(kubectl -n "$NS" get deploy "$DEPLOY" -o jsonpath='{.status.readyReplicas}') +[ "${READY:-0}" = "$REPLICAS" ] || die "readyReplicas=$READY, expected $REPLICAS" +log "deployment reports $READY/$REPLICAS Ready" + +# ── 5. probe /_readiness on EVERY relay pod (not just the deployment aggregate) +# The bundled MinIO + init pods share app.kubernetes.io/name=buzz, so select by +# the relay Deployment's own pod-template hash to hit only relay pods. +log "probing /_readiness on each relay pod individually" +: > "$EVID/readiness.txt" +FAIL=0 +RELAY_PODS=$(kubectl -n "$NS" get pods \ + -l "app.kubernetes.io/name=buzz,app.kubernetes.io/instance=$RELEASE" \ + -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.metadata.labels.app\.kubernetes\.io/component}{"\n"}{end}' \ + | awk '$2 != "minio" && $2 != "minio-init" {print $1}') +for pod in $RELAY_PODS; do + body=$(kubectl -n "$NS" exec "$pod" -- \ + sh -c 'curl -sS --max-time 5 http://127.0.0.1:8080/_readiness' 2>/dev/null || echo '') + echo "$pod -> $body" | tee -a "$EVID/readiness.txt" + echo "$body" | grep -q '"status":"ready"' || FAIL=1 +done +[ "$FAIL" = 0 ] || die "at least one pod is not reporting ready (see $EVID/readiness.txt)" + +log "ALL $REPLICAS PODS READY — baseline up. Evidence: $EVID" +echo "namespace=$NS release=$RELEASE image=${IMAGE_REPO}:${IMAGE_TAG}" | tee "$EVID/SUMMARY.txt" diff --git a/deploy/local/quickstart-ha-values.yaml b/deploy/local/quickstart-ha-values.yaml new file mode 100644 index 0000000000..540435cf54 --- /dev/null +++ b/deploy/local/quickstart-ha-values.yaml @@ -0,0 +1,44 @@ +# Local docker-desktop HA testbed: quickstart bundled services + 3 replicas. +# +# This is the baseline every mesh build redeploys onto (mesh lane, thread +# fa90bba8 / kickoff 5a760d8d). It layers the HA shape on top of the CI +# quickstart set (ci/quickstart-values.yaml): bundled Postgres/Redis/MinIO, +# chart-autogenerated secrets, membership enforcement off. +# +# Two lines carry HA: +# replicaCount: 3 — chart hard-requires Redis for buzz-pubsub +# (quickstart provides it); the git-hook HMAC +# secret autogenerates chart-side (secret-chart.yaml). +# persistence.git.enabled — false → per-pod emptyDir. The default (true) is a +# single ReadWriteOnce PVC that cannot multi-attach +# across 3 pods on one node; it would wedge 2/3. +# Object store + Postgres are the source of truth +# for git state, not local disk (values.yaml docs +# emptyDir as the correct multi-replica choice). +quickstart: true +postgresql: + enabled: true +redis: + enabled: true +minio: + enabled: true +relayUrl: wss://buzz.test.local +ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000001" +relay: + # Testbed, not business-logic validation — same rationale as CI quickstart. + requireRelayMembership: false +podDisruptionBudget: + enabled: false + +# ── HA shape ────────────────────────────────────────────────────────────── +replicaCount: 3 +persistence: + git: + enabled: false + +# Locally-built image; docker-desktop k8s shares the docker image store, so +# IfNotPresent resolves the tag with no push / `kind load` step. +image: + repository: buzz-relay + tag: mesh-local + pullPolicy: IfNotPresent diff --git a/desktop/.gitignore b/desktop/.gitignore index 8c39bf90fb..4d3e0c5ac5 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -12,6 +12,7 @@ node_modules dist dist-ssr playwright-report +playwright-report.json test-results *.local playwright-report diff --git a/desktop/index.html b/desktop/index.html index a2073d47f1..c219f214dc 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -5,6 +5,49 @@ + + + diff --git a/desktop/package.json b/desktop/package.json index 8be1aae9d7..daa5f93b1f 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.3.43", + "version": "0.4.6", "type": "module", "scripts": { "dev": "vite", @@ -9,8 +9,9 @@ "typecheck": "tsc --noEmit", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:px-text": "node ./scripts/check-px-text.mjs", + "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", "lint": "biome lint .", - "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text", + "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", "test": "node --import ./test-loader.mjs --experimental-strip-types --test 'src/**/*.test.mjs'", "preview": "vite preview", @@ -76,6 +77,7 @@ "tailwind-merge": "^3.5.0", "tiptap-markdown": "^0.9.0", "upng-js": "^2.1.0", + "virtua": "0.49.3", "yaml": "^2.8.3", "zod": "^4.4.3" }, diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index f5ac116bac..c6d9d1bccf 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -36,11 +36,24 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/core-memory-screenshots.spec.ts", + "**/activity-scope-label-screenshots.spec.ts", + "**/welcome-agent-modal-screenshots.spec.ts", + "**/local-archive-screenshots.spec.ts", + "**/agent-readiness-screenshots.spec.ts", + "**/agent-error-state-screenshots.spec.ts", + "**/edit-agent.spec.ts", + "**/doctor-cta-screenshots.spec.ts", + "**/pubkey-display-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/image-attachment-gallery.spec.ts", + "**/composer-image-draw.spec.ts", "**/video-attachment.spec.ts", "**/spoiler.spec.ts", + "**/composer-link-shortcut.spec.ts", + "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", + "**/team-mentions.spec.ts", "**/relay-reconnect.spec.ts", "**/relay-reconnect-affordance.spec.ts", "**/workflows.spec.ts", @@ -53,21 +66,44 @@ export default defineConfig({ "**/top-chrome-zoom-clearance.spec.ts", "**/thread-unread.spec.ts", "**/workspace-rail.spec.ts", + "**/boot-splash.spec.ts", "**/thread-reply-anchor-roleplay.spec.ts", "**/threadpane-ultrawide.spec.ts", "**/animated-avatar.spec.ts", "**/reminders.spec.ts", + "**/reminder-click-repro.spec.ts", "**/virtualization.spec.ts", "**/scroll-history.spec.ts", "**/channel-dense-second-reach.spec.ts", "**/channel-window-mock-paging.spec.ts", "**/live-broadcast-reply-timeline.spec.ts", + "**/markdown-parse-cache.spec.ts", "**/overscroll-boundary.spec.ts", "**/cold-switch-longtask.perf.ts", "**/timeline-no-shift.spec.ts", "**/human-edit-agent-content.spec.ts", "**/reaction-order.spec.ts", + "**/reaction-names.spec.ts", + "**/inbox-reactions.spec.ts", "**/send-channel-binding.spec.ts", + "**/project-commit-detail.spec.ts", + "**/project-pr-review.spec.ts", + "**/persona-model-combobox-screenshots.spec.ts", + "**/drafts-screenshots.spec.ts", + "**/buzz-theme-screenshots.spec.ts", + "**/channel-sort.spec.ts", + "**/identity-lost.spec.ts", + "**/global-agent-config-screenshots.spec.ts", + "**/doctor-states.spec.ts", + "**/onboarding-avatar-skip.spec.ts", + "**/onboarding-backup.spec.ts", + "**/onboarding-agent-defaults.spec.ts", + "**/nostr-bind.spec.ts", + "**/profile-nsec-reveal.spec.ts", + "**/agent-provider-dropdowns.spec.ts", + "**/agent-lifecycle-feedback.spec.ts", + "**/inbox-live-update.spec.ts", + "**/mesh-compute.spec.ts", ], use: { ...devices["Desktop Chrome"], @@ -77,6 +113,7 @@ export default defineConfig({ name: "integration", testMatch: [ "**/agents.spec.ts", + "**/agent-snapshot-recipient.spec.ts", "**/onboarding.spec.ts", "**/stream.spec.ts", "**/integration.spec.ts", @@ -86,7 +123,7 @@ export default defineConfig({ "**/tokens.spec.ts", "**/persona-env-vars.spec.ts", "**/persona-sync.spec.ts", - "**/mesh-compute.spec.ts", + "**/team-snapshot.spec.ts", "**/parity-ancestor-island.spec.ts", ], use: { diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index e5d5f2e236..d346116132 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -60,7 +60,26 @@ const overrides = new Map([ // config-bridge: get_agent_config_surface/write_agent_config_field/put_agent_session_config // commands add ~40 lines. Queued to split. // branch cut; override bumped to cover the merged total. Queued to split. - ["src-tauri/src/commands/agents.rs", 1437], + // persona-blank-fallback: persona_snapshot_with_agent_config_fallback call + // sites add ~4 lines (extra fallback params + inline comments). build_deploy_payload + // fix (blank-persona provider/model fallback) adds ~6 lines. Bug fix. + // archive/mod_tests.rs carries the full test module for archive/mod.rs: + // unit tests + 4 real-relay integration tests (ignored, live-relay only). + // Production logic in mod.rs is now ~527 lines (under 1000). mod_tests.rs + // is test-only content; the override covers the test growth accumulated + // across the local-archive + agent-metric-archive PR series. store_tests.rs + // (~731 lines) is under 1000 so needs no override. + ["src-tauri/src/archive/mod_tests.rs", 1208], + // unified-agent-model 1A.1: profile reconcile split to agents_profile.rs, + // ratcheting 1443 -> 1295. Queued to split further in the A2 fold. + // global-agent-config: resolve_deploy_model_provider + visibility exports + // add ~40 lines on top of the 1A.1 ratchet. Queued to split. + ["src-tauri/src/commands/agents.rs", 1340], + // agent-lifecycle-fixes: cascade-delete in delete_persona restructured into + // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for + // retry-safety. Load-bearing reviewer-required change; queued to split. + // Consolidation removed the legacy persona-card import/export codecs. + ["src-tauri/src/commands/personas/mod.rs", 984], // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS // const + build_thread_replies_filter helper, mirroring the channel sibling so // the two p-gate filters can't drift) plus two guard unit tests. The file was @@ -75,7 +94,21 @@ const overrides = new Map([ // to split with the rest of this list. // dev-nest namespace: OnceLock> + init_nest_dir + constants // added to plumb the dev/prod discriminator. Load-bearing for the D2 nest fix. - ["src-tauri/src/managed_agents/nest.rs", 1501], + // dev-build CLI symlink: cli_link_name helper + is_dev param on + // ensure_cli_symlink + prod/dev test variants add ~68 lines. Load-bearing; + // queued to split with the rest of this list. + // +4 lines: adopt shared create_symlink wrapper (behavior-preserving refactor + // for multi-line rustfmt expansion of the skills symlink call site). + // unified-agent-model 1A.1: inline test module moved to nest/tests.rs, + // ratcheting 1575 -> 679 (under the 1000 default; entry kept as a ratchet). + // observer-archive dev-default: path_is_dev_nest + nest_is_dev getters + // (+25 lines) so observer_archive_default_enabled() keys off the dev nest. + // Load-bearing; spends banked ratchet headroom, still well under 1000. + ["src-tauri/src/managed_agents/nest.rs", 704], + // keyring-dev-isolation: agent key migration added copy_agent_keys_between_stores + // and load_readonly support; file grew past 1000 default. Queued to split. + // +7 for try_delete_agent_key result-returning seam (snapshot-import rollback). + ["src-tauri/src/managed_agents/storage.rs", 1335], // harness-persona-sync: persona-runtime resolution threaded into the spawn // path here. Load-bearing feature growth; queued to split in the resolver // unify refactor followup. +26 for resolve_effective_prompt_model_provider @@ -83,7 +116,58 @@ const overrides = new Map([ // PGID resolution helper + PID-recycling safety guard added for orphan sweep. // activity-feed threads avatar_url into build_managed_agent_summary for the // assistant-bubble pinned snapshot. - ["src-tauri/src/managed_agents/runtime.rs", 2150], + // +1 for agent_pubkey field in setup payload (config-nudge card wire). + // persona-blank-fallback: resolve_effective_prompt_model_provider gains a + // record_provider param + applies persona_field_with_record_fallback. +5 lines. + // global-agent-config: spawn_agent_child loads global config and merges as + // lowest env layer (+8 lines). Queued to split. + ["src-tauri/src/managed_agents/runtime.rs", 2216], + // config-bridge setup-payload env-boundary fix adds readiness wiring in + // spawn_agent_child; load-bearing security fix, queued to split. + ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], + // config-bridge-aware requirements: goose_requirements + injection tests + // (4 new tests in goose_file_config_tests module) + test-determinism fixes + // for the 3 existing goose tests that previously read real disk config. + // New file in this PR; queued to split. + // +2 readiness integration tests for flat-DATABRICKS_HOST canonicalization fix. + // +1 cargo fmt whitespace reformat (readiness.rs closures inline after rebase). + // +2 unit tests for cli_login_requirements resolve_command integration (DMG PATH fix). + // Doctor-CTA: reworked cli_login_requirements to carry AcpAvailabilityStatus, + // skip login probe for not-installed/adapter-missing/cli-missing states, and + // added 4 unit tests covering each arm. Load-bearing discoverability fix. + // Updated existing codex_not_ready test to use make_cli_runtime stub. + // +4 lines: #1640 persona-env-vars-refresh rebase added availability-classification + // growth in the live-persona env merge path. Feature plumbing, not generic debt. + // Windows-CI portability: replaced POSIX true/false probes with current_exe() + // stand-in + present_binary_str()/static_commands() helpers (+29 lines). + // Tests now pass on windows-latest CI shard without POSIX shell utilities. + // databricks-v1-to-v2-migration: databricks-v2 hyphen-alias added to all + // host/credential match arms + 30+ readiness tests for provider aliases, + // missing-host, and DATABRICKS_MODEL fallback. Load-bearing correctness fix. + // #1613 augmented-PATH readiness probes grew the file +3 past the prior cap. + // +16: resolve_effective_agent_env + global-config readiness wiring (#1448). + // +1 rebase merge: GlobalAgentConfig import added alongside AcpAvailabilityStatus. + // +2 rebase onto #1667: behavioral quad fields in AgentDefinition/ManagedAgentRecord. + // +3 rebase onto main (#1568 + #1613): identity-import-keyring + augmented-PATH probes. + // +18: CliConfigInvalid requirement surface for config-parse probe classification — + // new Requirement variant + updated cli_login_requirements + 3 new probe-layer tests. + // Load-bearing UX fix (bad config → clear diagnostic, not "run codex login"). + // codex-acp-package-swap: AdapterOutdated version-probe in cli_login_requirements + // (+22 lines). Load-bearing — blocks login gate for deprecated 0.16.x adapter. + // code-reviewer fix-round: codex readiness gate tests — 2 new tests for + // outdated-adapter and garbage-version-output paths through the codex id gate + // (+140 lines: make_codex_runtime helper, PATH_MUTEX serializer, 2 test fns). + // Load-bearing test coverage; queued to split with the file generally. + // +1: pub(crate) mod cli_probe declaration for doctor auth probe access. + // +3: auth_probe_args: None + login_hint: None added to make_cli_runtime and + // make_codex_runtime stubs (new KnownAcpRuntime fields). + // Git Bash readiness is intentionally colocated with buzz-agent's other + // setup-mode requirements. The Windows-only requirement and serialization + // test add eight lines; split remains queued with the existing file debt. + // Windows Doctor install fix: cli_install_commands_windows field added to test stubs. + // team-instructions-first-class: ManagedAgentRecord fixture gains the new + // team_id field (+1 line). + ["src-tauri/src/managed_agents/readiness.rs", 1765], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing @@ -94,8 +178,60 @@ const overrides = new Map([ // #1418 read-path fix: +3 doc-only lines correcting the getThreadReplies // contract (replies-only, root excluded — the query keys on root_event_id, // which root rows lack). Documentation accuracy, not code growth. - // linux-updater-manual: isAutoUpdateSupported() binding adds 7 lines. - ["src/shared/api/tauri.ts", 1347], + // linux-updater isAutoUpdateSupported() binding + onboarding has_profile_event field. + // config-bridge-aware requirements: getRuntimeFileConfig command adds ~15 lines. + // +26 lines from PRs landing on main between prior rebase and this rebase. + // baked-env-required-badge: getBakedBuildEnvKeys wrapper adds ~16 lines. Queued to split. + // restart-badge: started the queued split — start/stopManagedAgent moved to + // tauriManagedAgents.ts; limit ratcheted down 1388 → 1380 to bank the headroom. + // identity-import-keyring: identity wrappers (RawIdentity, getIdentity, getNsec, + // importIdentity, persistCurrentIdentity) moved to tauriIdentity.ts; + // limit ratcheted down 1380 → 1360 to bank the headroom (absorbs main-side + // growth landed between the split and the rebase). + // mention-alias fix: profile wrappers (RawProfile/RawUserProfileSummary types, + // getProfile/updateProfile/getUserProfile/getUsersBatch/searchUsers) moved to + // tauriProfiles.ts; limit ratcheted down 1360 → 1241 to bank the headroom. + // baked-env fold-in: getBakedBuildEnv + BakedEnvEntry type adds ~28 lines. + // doctor-npm-eacces-preflight: hint field on RawInstallStepResult + mapper + // passthrough (+2 lines). + // doctor-install-reliability: node_required + auth_status + login_hint fields + // added to RawAcpRuntimeCatalogEntry + fromRawAcpRuntimeCatalogEntry mapper (+8). + // codex-install-auto-restart: restarted_count + failed_restart_count added to + // RawInstallRuntimeResult + fromRawInstallRuntimeResult mapper (+2). + // Git Bash Doctor discovery adds the raw Tauri response and its camelCase + // mapper. This is the existing API boundary; split remains queued. + // team-instructions-first-class: createManagedAgent Tauri bridge threads the + // new teamId input through to the backend (+1 line). + ["src/shared/api/tauri.ts", 1305], + // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). + // codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line). + // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ + // loginHint fields on AcpRuntimeCatalogEntry (+14 lines). Load-bearing new feature. + // agent-lifecycle-fixes: GlobalAgentConfigSaveResult type grows with + // failed_restart_count (+2 lines). Queued to split with the rest of this list. + // mcp-readonly-view rebase: PR2 MCP config surface FE-type fields force +1 over the grandfathered ceiling. + // Git Bash prerequisite payload adds four fields to the shared Tauri API + // contract. This is the canonical type location; split remains queued. + // signout-wipe: resetFailed field added to Identity type (+6 lines). + // team-instructions-first-class: CreateManagedAgentInput.teamId (+2, incl. + // doc comment) and AgentTeam/CreateTeamInput/UpdateTeamInput.instructions + // (+3) — the new team-id spawn link and the runtime-layered instructions + // field. + ["src/shared/api/types.ts", 1047], + // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog + // shows required markers and credential amber rows (parity with + // CreateAgentDialog). +23 lines of gate wiring. Queued to split. + // config-bridge-aware requirements: useRuntimeFileConfigQuery wiring adds + // ~16 lines. Queued to split. + // baked-env-required-badge: useBakedBuildEnvKeysQuery + bakedEnvKeys wiring + // + correct exclusion-semantics for requiredEnvKeys adds ~14 lines. + // +2 lines: filter managed provider key from requiredEnvKeys (suppress dead-input locked row). + // global-agent-config parity: wire useGlobalAgentConfig into PersonaDialog + // (Gap A: global-aware computeLocalModeGate + drop bare requiredCredentialEnvKeys; + // Gap B: hasAutoOpenedAdvancedRef auto-expand effect) + effective-provider + // save gate + Inherit/Select-a-provider label. Queued to split. + ["src/features/agents/ui/PersonaDialog.tsx", 1080], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); @@ -106,15 +242,84 @@ const overrides = new Map([ // config-parity: max_tokens_env_var + context_limit_env_var fields added to // KnownAcpRuntime (2 fields × 4 runtimes + discovery tests = ~13 lines). // Load-bearing — required for buzz-agent normalized config parity. - ["src-tauri/src/managed_agents/discovery.rs", 1124], + // same-runtime-pin: update_time_agent_command_override + its override / + // same-runtime / alias / sentinel / non-override / persona-less test matrix + // (~135 lines, mostly tests) so a deliberate Custom pin survives the update + // path instead of being dropped back to inherit. Load-bearing, not debt. + // unified-agent-model 1A.1: inline test module moved to discovery/tests.rs, + // ratcheting 1259 -> 802 (under the 1000 default; entry kept as a ratchet). + // agent-config-propagation: the agent_command_override decision family + // (divergent / create-time / update-time / apply) moved to + // discovery/overrides.rs; ratcheting 802 -> 685 to bank the headroom. + // codex-acp-package-swap: probe_codex_acp_major_version (+24 lines) + + // AdapterOutdated version-gate in discover_acp_runtimes (+22 lines). Both + // load-bearing — required to detect the deprecated 0.16.x adapter and + // prevent silent relay breakage after the spawn-contract change. + // codex-acp-package-swap follow-up: tempfile-based bounded stdout read + // (+18 lines), codex_adapter_availability/is_outdated helpers (+16 lines), + // cross-platform probe contract. All load-bearing — required for correct + // probe behaviour on Windows and descendant-process edge cases. + // doctor-install-reliability: refreshable login_shell_path cache, + // find_nvm_default_bin + parse_semver_tag helpers, auth probe cache + + // probe_auth_status/cached_auth_status, runtime_needs_npm, probe_args_for, + // PartialEntry struct, and updated discover_acp_runtimes with parallel auth + // probes. Load-bearing fresh-install reliability fixes. (+289 lines) + // doctor-install-reliability review fixes: LoginShellPath enum + double-checked + // locking, is_safe_nvm_tag security validation, classify_probe_output helper, + // auth_probe_args on KnownAcpRuntime (removes probe_args_for indirection), + // process-level timeout replacing inner-thread pattern. (+75 lines) + // codex-install-auto-restart review-fixes: availability_drift pure predicate + // + updated adapter_availability_cached() signature (Option return, cold=None) + // prevents false restart badge on newly restarted agents. Correctness fix; + // load-bearing — required by Thufir's IMPORTANT findings. (+15 lines) + // Windows Doctor install fix: cli_install_commands_windows field, impl block + // for cli_install_commands_for_os(), command_basenames() + .cmd/.bat resolution, + // Windows well-known dirs in common_binary_paths(), login_shell_candidates(), + // path_candidates_from_env_raw(). Load-bearing Windows platform support. + // +13: fetch_login_shell_path_inner Windows guard (POSIX PATH → None). + // resolve_git_bash made pub(crate) for Windows test access. + // +1: login_shell_candidates doc comment expanded for resolve_bash_path. + ["src-tauri/src/managed_agents/discovery.rs", 1366], + // rebase over codex-acp-package-swap: its version-probe tests union with the + // doctor-install-reliability nvm/login-shell/semver tests — each side alone + // stayed under the 1000 default; the union exceeds it. + // Windows Doctor install fix: command_basenames, cli_install_commands_for_os, + // and login_shell_candidates tests. Load-bearing platform-awareness coverage. + // +132: pass 2 — five cfg(windows) behavioral tests: command_basenames .cmd/.bat + // candidates, cli_install_commands_for_os PowerShell selection, login_shell_path + // None regression, .cmd shim resolution, no-git-bash error hint. + // +32: deterministic .cmd resolver + no-registry + install_shell_from tests. + // team-instructions-first-class: record_with test fixture gained the new + // ManagedAgentRecord.team_id field (+1 line) alongside persona_team_dir. + ["src-tauri/src/managed_agents/discovery/tests.rs", 1271], + // identity-import-keyring: the identity resolution state machine's behavioral + // matrix (46 tests over FakeIdentityStore — probe × marker × file cells, + // adoption / read-back-corruption / marker-failure arms, recovery-mode + // gating). Load-bearing regression coverage for silent identity rotation, + // not generic debt growth. Approved override; split if the matrix grows. + ["src-tauri/src/app_state_tests.rs", 1420], // migration_tests.rs carries the harness-sync migration coverage plus the // patch_json_records owner-only writeback regression test (SECURITY.md:90 // crash-safe 0o600 fallback). Load-bearing security + feature coverage, not - // generic debt growth. Approved override; still queued to split. - ["src-tauri/src/migration_tests.rs", 1410], + // generic debt growth. Approved override; still queued to split. Event-sync + // (persona/team event reconcile) tests were split out to event_sync_tests.rs + // and the limit ratcheted 1410 → 1110. + // unified-agent-model 1A.1: materialize tests live with their module in + // migration/materialize.rs; ratchet held at 1110. + ["src-tauri/src/migration_tests.rs", 1110], ["src-tauri/src/nostr_convert.rs", 1126], ["src/shared/api/relayClientSession.ts", 1022], - ["src-tauri/src/migration.rs", 1575], + // Boot-time event sync (persona/team/agent event reconcile) was split out + // to event_sync.rs, ratcheting this limit 1575 → 1310. Remaining content is + // the pre-identity data migrations; still queued to split further. + // unified-agent-model 1A.1: materialize_agent_runtimes split to + // migration/materialize.rs, ratcheting 1310 -> 1297. + // databricks-v1-to-v2-migration: reconcile_databricks_v1_to_v2 migration + // + inner fn with baked-env gate + 26 tests. Load-bearing correctness fix. + // am review fix: also clear stale V1 model field on provider rewrite + + // new model-clear test. Load-bearing chimera fix. + // keyring-dev-isolation: run_boot_migrations wires agent-key migration. + ["src-tauri/src/migration.rs", 1436], // onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop // already here) for the single-toggle mark-read/unread menu item — a small // overage from load-bearing per-message plumbing, not generic debt growth. @@ -126,6 +331,9 @@ const overrides = new Map([ // +135 for AgentInfoFocusedView/DiagnosticsFocusedView/ChannelsFocusedView // props restored after 826d735fe removal (UserProfilePanel.tsx still needs them). ["src/features/profile/ui/UserProfilePanelSections.tsx", 1140], + // +14 for openEditAgent event subscription (config-nudge card "Open Edit Agent" action). + // +11 for editAgentFocus state + initialFocus prop threading (deep-link granularity). + ["src/features/profile/ui/UserProfilePanel.tsx", 1025], // PersistBackend enum + marker-on-keyring-success plumbing and its three // fail-closed regression tests (silent identity rotation on keyring outage). // A small overage from load-bearing security plumbing on a file already at @@ -133,8 +341,27 @@ const overrides = new Map([ // cross-process keychain race fix (D3): interprocess lock + BlobLockGuard + // uid-keyed lockfile path + behavioral tests add ~303 lines. Load-bearing // security fix for the lost-update race that stranded agent keys. - ["src-tauri/src/secret_store.rs", 1043], - ["src-tauri/src/app_state.rs", 1033], + // identity-import-keyring: KeyringLockedScreen, RecoveryScreen, + // load_readonly + load_all_readonly + store_all for safe cross-service reads. + // sign-out wipe: delete_all() method removes the entire keychain blob under + // the interprocess advisory lock; +8 lines. Load-bearing; queued to split. + // signout-wipe phase 2: delete_all_with_legacy_cleanup replaces delete_all; + // reads blob keys + deletes per-key legacy entries to prevent resurrection. + // + regression test for per-key resurrection via real OS keychain. + // Net growth ~36+32 lines over prior cap. Load-bearing correctness fix. + // signout-wipe pass-2 (F2): delete_all_with_legacy_cleanup DPK deletes now + // observable (propagate real errors); verify_fully_wiped checks all three + // keychain shapes (main blob, DPK blob, per-key "identity"). +73 lines. + ["src-tauri/src/secret_store.rs", 1307], + // sign-out wipe: Sign Out section (AlertDialog + controlled state) added + // at the bottom of the Profile settings page. Load-bearing UX feature; + // queued to split when ProfileSettingsCard is broken into sub-components. + // +20 lines: scroll-position save/restore across avatar editor open/close + // to prevent layout shift from the Sign Out section causing a viewport jump. + ["src/features/settings/ui/ProfileSettingsCard.tsx", 1033], + // keyring-dev-isolation: keyring_service() fn (7 lines) replaces the const + // to return "buzz-desktop-dev" in debug builds. Load-bearing isolation fix. + ["src-tauri/src/app_state.rs", 1042], // multi-slot splitting + no-op suppression (#1309): the ReadStateManager // class grew from ~700 lines to ~1019 with the addition of // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, @@ -142,9 +369,26 @@ const overrides = new Map([ // test. Load-bearing feature growth, queued to split publishSplitSlots path // into readStateManagerSplit.ts. ["src/features/channels/readState/readStateManager.ts", 1030], + // review feedback on #1492 restored the two-line load-bearing comment + // documenting why `lastMessageAt` must not be an `activeReadAt` fallback + // (reply-inclusive; would clear unread state early). The file was already + // at the 1000 ceiling; comment-only overage, not code growth. Queued to + // split with the rest of this list. + // member-agent-flags: messageProfiles merge + ref stabilisation split out to + // useMessageProfiles.ts, ratcheting 1002 -> 972 (under the 1000 default; + // entry kept as a ratchet). +7 rebase onto main (#1698 timeline-window + // growth), 972 -> 979. + ["src/features/channels/ui/ChannelScreen.tsx", 979], + // forced-unread persistence: markChannelUnread now writes through to + // forcedUnreadStore (localStorage) so the sidebar badge survives reload and + // the rail observer can read it. Three clear points added (markChannelRead, + // markAllChannelsRead, drainSyncedAdvances). Load-bearing fix, not generic + // debt growth. Queued to split with the rest of this list. + ["src/features/channels/useUnreadChannels.ts", 1022], // Shared UI was added to this guard after splitting globals/markdown so // large shared renderers cannot grow further while follow-up splits land. - ["src/shared/ui/markdown.tsx", 2119], + // +33 for config-nudge detect-and-render + author-auth gate (normalizePubkey guard). + ["src/shared/ui/markdown.tsx", 2152], ["src/shared/ui/VideoPlayer.tsx", 2199], ["src/shared/ui/sidebar.tsx", 1042], // permission-outcome (fix #1381 regression): pendingPermissions state map, @@ -157,8 +401,11 @@ const overrides = new Map([ // (current_mode_update, usage_update, available_commands_update, // config_option_update) + replaceLifecycleItem helper for usage coalescing + // system-prompt ordering fix (turnId: null for per-channel items). + // +35: session/new reposition-on-refire fix — removeItem helper + + // upsertMetadata restart branch (remove+sealOpenMessages+push instead of + // replaceItem in-place) so system-prompt anchor moves to stream tail. // Load-bearing feature growth; queued to split in next transcript refactor. - ["src/features/agents/ui/agentSessionTranscript.ts", 1167], + ["src/features/agents/ui/agentSessionTranscript.ts", 1202], // catalog module; agent_models.rs retains the thin wrapper (~50 lines). // File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess // fallback. Queued to split into dedicated discovery modules. @@ -170,7 +417,76 @@ const overrides = new Map([ // a GUI-launched DMG (the discovery_env_with_baked_floor fold). // +3: provider tri-state applied in update_managed_agent handler // (if let Some(provider_update) = input.provider { record.provider = provider_update; }). - ["src-tauri/src/commands/agent_models.rs", 1071], + // +8: harness_override thread-through in update_managed_agent so a deliberate + // Custom pin routes to update_time_agent_command_override (comment + call). + ["src-tauri/src/commands/agent_models.rs", 1079], + // global-agent-config: get_agent_config_surface / write_agent_config_field / + // put_agent_session_config commands + GlobalAgentConfig serde types. New file + // in this PR; queued to split with the command module refactor. + // +17: baked-env-global-unify: BUZZ_AGENT_THINKING_EFFORT added to + // is_safe_to_reveal allowlist + baked_env_thinking_effort_is_unmasked test. + // +1: doctor-install-reliability: login_hint: None added to goose_runtime test stub. + // +1: doctor-install-reliability review fixes: auth_probe_args: None added to stub. + ["src-tauri/src/commands/agent_config.rs", 1021], + // codex-install-auto-restart review-fixes: should_restart_after_install + // takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy + // cache tests replaced with 6 pure availability_drift predicate tests; + // dead-pid non-happy-path added. All load-bearing correctness fixes. + // (+17 lines net vs previous 1330 limit; rustfmt expanded some call sites) + // Git Bash Doctor discovery exposes a narrow async Tauri command at the + // existing discovery boundary. The ten-line addition preserves the platform + // neutral frontend contract; split remains queued. + // Windows Doctor install fix: resolve_install_shell() + install_shell_command() + // returns Result (Windows Git Bash resolution, CREATE_NO_WINDOW, taskkill timeout + // kill), cli_install_commands_for_os() callsite, unit tests for shell selection + // and per-OS install command accessor. Load-bearing Windows platform support. + // +53: pass 2 — three cfg(windows) install shell tests (resolve succeeds with + // Git, error hint content, install_shell_command succeeds). + // +8: install_shell_from pure seam extracted for deterministic testing. + ["src-tauri/src/commands/agent_discovery.rs", 1523], + // draft-persistence predicate: submit-time `loadDraft` check + inline comment + // + deps-array entry in submitMessage closes the never-persisted-boundary + // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to + // split MessageComposer into submit/edit/media sub-modules. + // +18: pendingImetaForPersistRef (local snapshot ref) + synchronous restore + // path writes in the draft-key effect body, fixing the image-drop bug on + // top-level nav switch (StrictMode simulate-unmount race on remount). + // +12 autoSubmitDraftKey/onAutoSubmitComplete props + onAutoSubmitCompleteRef + // + mount-only useEffect for the Drafts-panel "Send message" confirm-dialog + // flow. Load-bearing feature growth; queued to split with the rest of this + // list. + // +3: onLinkShortcutRef wiring (ref decl + editor option + assignment) for + // the ⌘K link-editor shortcut, mirroring the existing onEditLinkRef + // pattern. Queued to split with the rest of this list. + ["src/features/messages/ui/MessageComposer.tsx", 1036], + // global-agent-config: model-tuning section (BuzzAgentModelTuningFields via + // EditAgentAdvancedFields) + providerValid gate + effectiveProvider derivation + // + globalProvider threading into getPersonaProviderOptions. All load-bearing + // feature logic; queued to split with the rest of this list. + ["src/features/agents/ui/EditAgentDialog.tsx", 1088], + // global-agent-config rebase over #1639: AgentInstanceEditDialog (renamed from + // EditAgentDialog by #1639) gained initialFocus?/EditAgentFocusTarget prop + // threading from the deep-link focus feature, and isEditAgentProviderSaveValid + // extracted as a testable helper with originalRuntimeSupportsProvider to close + // the runtime-switch hole in Will's (b) providerValid gate narrowing. + // E2E-fix round: added globalProvider fallback to useRequiredCredentialState + // call site and buzz-agent auto-expand effect for model-tuning knob visibility. + // F1-fix: added globalEnvVars to useRequiredCredentialState so globally-satisfied + // credential keys are excluded from requiredEnvKeyMissing (display/gate parity). + // Feature logic, not generic debt. Approved override; still queued to split. + // +23 rebase onto #1667: behavioral quad fields (respond_to/parallelism/toolsets) + // plumbed through AgentInstanceEditDialog from PersonaAdvancedFields. + // +2 provider-aware effort: model/provider props threaded to BuzzAgentModelTuningFields. + // +15 provider/model dropdown fixes: useBakedBuildEnvKeysQuery + hideProviderIds + // for Databricks v1 gate; prospectiveRuntimeId default fallback for builtins. + // PR-B moves default/API-key derivation into shared hooks; the explicit + // hidden-key projection keeps the top-level secret out of Advanced rows. + ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1195], + // AgentDefinitionDialog grew past 1000 with the following load-bearing fixes: + // isRuntimeAutoSeededRef tracking for edit-mode seeding (Fizz shows models); + // runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix); + // hideProviderIds computation for Databricks v1 gate. Queued to split. + ["src/features/agents/ui/AgentDefinitionDialog.tsx", 1035], ]); await runFileSizeCheck({ diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs new file mode 100644 index 0000000000..95e56fb282 --- /dev/null +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -0,0 +1,48 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runPubkeyTruncationCheck } from "../../scripts/check-pubkey-truncation-core.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); + +// Truncated pubkey prefixes are forgeable (vanity grinding), so all display +// truncation goes through the canonical `truncatePubkey` / `` — this +// guard keeps ad-hoc `pubkey.slice(0, N)` forms from fragmenting again. +const rules = [ + { + root: "src", + extensions: new Set([".ts", ".tsx"]), + }, +]; + +// Non-display uses: array windows over pubkey lists, color/initials +// derivation where the value is never presented as an identity. +const overrides = new Set([ + // ProfileAvatar fallback label — decorative glyphs inside an avatar disc. + "src/features/huddle/components/ParticipantList.tsx:92", + // HexAvatar: 6-char badge + hue derivation inside a color-coded disc, + // clearly decorative (paired with a full truncatePubkey aria-label). + "src/features/huddle/components/ParticipantList.tsx:143", + "src/features/huddle/components/ParticipantList.tsx:144", + // clientId (not a pubkey) sliced in a debug log next to the real thing. + "src/features/channels/readState/readStateManager.ts:338", + // Array windows (first N pubkeys), not string truncation. + "src/features/messages/lib/threadPanel.ts:395", + "src/features/projects/ui/ProjectsView.tsx:166", + "src/features/projects/ui/ProjectsOverviewPanel.tsx:209", +]); + +await runPubkeyTruncationCheck({ + projectRoot, + rules, + overrides, + allowedFiles: new Set([ + // The canonical helper itself. + "src/shared/lib/pubkey.ts", + // E2E mock bridge fabricates ids/nsecs from pubkeys; nothing here is a + // user-facing identity display. + "src/testing/e2eBridge.ts", + ]), + label: "Desktop", + scriptPath: "desktop/scripts/check-pubkey-truncation.mjs", +}); diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index 8182d18cd7..02f663ac3d 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -19,12 +19,15 @@ const rules = [ }, ]; -// Decorative / chrome exceptions: `relativePath:lineNumber`. The avatar emoji -// glyph is a fixed display size sized to its avatar box (not readable message -// text), so it stays as the lone documented `text-[6rem]` literal. +// Decorative / chrome exceptions: `relativePath:matchedLiteral`. The avatar +// emoji glyphs are fixed display sizes sized to their avatar box (not readable +// message text), so they are exempted from the readable-text px rule. Matching +// the literal keeps these exceptions stable when unrelated edits move lines. const overrides = new Set([ - "src/features/settings/ui/ProfileSettingsCard.tsx:584", - "src/features/onboarding/ui/AvatarStep.tsx:89", + "src/features/settings/ui/ProfileSettingsCard.tsx:text-[6rem]", + "src/features/onboarding/ui/AvatarStep.tsx:text-[6rem]", + "src/features/agents/ui/AgentCreationPreview.tsx:text-[4rem]", + "src/features/agents/ui/AgentCreationPreview.tsx:text-[6rem]", ]); await runPxTextCheck({ diff --git a/desktop/scripts/fix-appimage.sh b/desktop/scripts/fix-appimage.sh new file mode 100755 index 0000000000..45f537bb72 --- /dev/null +++ b/desktop/scripts/fix-appimage.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# fix-appimage.sh — Remove infra libs from a Tauri-produced AppImage that crash +# on Mesa 25+ / GLib 2.88 distros (Ubuntu 26.04, Fedora 42+, etc.). +# +# Usage: fix-appimage.sh +# +# Set TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PASSWORD to +# re-sign after repacking (CI release builds). Without them the script +# repacks but skips signing, which is fine for local testing. +# +# Set APPIMAGETOOL_RUNTIME_FILE to a pre-downloaded AppImage type2 runtime to +# avoid appimagetool fetching one from its mutable `continuous` tag (CI pins +# this; unset is fine for local testing). +# +# Root cause — three interlocking failures (upstream: https://github.com/tauri-apps/tauri/issues/15665): +# +# 1. EGL crash: linuxdeploy bundles libwayland-client.so.0 (1.22) alongside +# the app. Mesa 25's libEGL calls the bundled version at runtime; the version +# skew causes eglGetDisplay to return EGL_BAD_PARAMETER under Wayland, which +# WebKitWebProcess treats as fatal and aborts before the window ever appears. +# +# 2. GStreamer crash: linuxdeploy also bundles libgst*.so* (GStreamer core libs). +# AppRun unconditionally sets GST_PLUGIN_SYSTEM_PATH_1_0 to a dir inside the +# AppImage that the bundler never populates (bundleMediaFramework is false by +# default), so GStreamer's plugin discovery yields an empty registry. The +# "GStreamer element appsink not found" error kills the render process; as a +# side effect the broken run poisons ~/.cache/gstreamer-1.0/registry.x86_64.bin. +# +# 3. WebKit helper mismatch (latent): the bundled WebKit helpers +# (WebKitNetworkProcess/WebKitWebProcess) have RUNPATH=$ORIGIN only, and +# linuxdeploy string-patches /usr -> ././ inside libwebkit2gtk so the helper +# dir is resolved relative to the process cwd. AppRun's chdir($APPDIR/usr) +# makes this work; any launch that bypasses AppRun (extracted-AppDir usage, +# repack workflows, dbus/systemd activation with cwd=/) resolves the helpers +# wrong -- spawning nothing, dying on unresolved bundled libs, or spawning +# the system helpers -- and the window never appears. +# +# Fix: remove the offending libs so the app uses the system copies (which are +# newer and ABI-compatible on any distro shipping glib >= 2.72 / Ubuntu 22.04+), +# and symlink the system GStreamer plugin directory so discovery works correctly. +# No tauri.conf.json knob can do this — bundle.linux.appimage only exposes +# bundleMediaFramework, files (copy-only, no remove/symlink), and bundleXdgOpen. + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: fix-appimage.sh " >&2 + exit 1 +fi + +if [[ ! -f "$1" ]]; then + echo "Error: file not found: $1" >&2 + exit 1 +fi + +APPIMAGE_ABS="$(realpath "$1")" +APPIMAGE_DIR="$(dirname "$APPIMAGE_ABS")" +APPIMAGE_NAME="$(basename "$APPIMAGE_ABS")" + +# Locate the desktop/ directory (this script lives at desktop/scripts/). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Detect multiarch triplet for GStreamer plugin path. +case "$(uname -m)" in + x86_64) MULTIARCH="x86_64-linux-gnu" ;; + aarch64) MULTIARCH="aarch64-linux-gnu" ;; + *) + echo "Error: unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +echo "==> Extracting $APPIMAGE_NAME" +(cd "$WORKDIR" && APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGE_ABS" --appimage-extract) + +LIBDIR="$WORKDIR/squashfs-root/usr/lib" + +# Guard against a bundler layout change: if the primary offending lib is not +# where we expect it, the rm globs below would silently no-op and we'd ship +# an unfixed artifact. Fail loudly instead so a tauri/linuxdeploy upgrade +# that changes the bundled lib set gets noticed here, not by users. +if ! compgen -G "$LIBDIR/libwayland-client.so*" > /dev/null; then + echo "Error: libwayland-client not found in $LIBDIR — bundler layout changed; update fix-appimage.sh" >&2 + exit 1 +fi + +echo "==> Removing infra libs that conflict with system Mesa / GLib / GStreamer" +rm -f \ + "$LIBDIR"/libwayland-client.so* \ + "$LIBDIR"/libwayland-cursor.so* \ + "$LIBDIR"/libwayland-egl.so* \ + "$LIBDIR"/libwayland-server.so* \ + "$LIBDIR"/libglib-2.0.so* \ + "$LIBDIR"/libgio-2.0.so* \ + "$LIBDIR"/libgobject-2.0.so* \ + "$LIBDIR"/libgmodule-2.0.so* \ + "$LIBDIR"/libmount.so* \ + "$LIBDIR"/libblkid.so* \ + "$LIBDIR"/libselinux.so* \ + "$LIBDIR"/libpcre2-8.so* \ + "$LIBDIR"/libgst*.so* \ + "$LIBDIR"/libzstd.so* \ + "$LIBDIR"/libelf.so* \ + "$LIBDIR"/libffi.so* + +echo "==> Symlinking system GStreamer plugin directory" +# On distros without the Debian multiarch layout (e.g. Arch), this symlink +# dangles — GStreamer then falls back to its default plugin discovery, which +# is a safe degradation (unlike the original empty in-bundle dir). +rm -rf "$LIBDIR/gstreamer-1.0" +ln -s "/usr/lib/$MULTIARCH/gstreamer-1.0" "$LIBDIR/gstreamer-1.0" + +echo "==> Repacking AppImage" +# Pass a pinned type2 runtime when provided (CI sets APPIMAGETOOL_RUNTIME_FILE); +# without it appimagetool downloads the runtime from its mutable `continuous` +# tag at repack time — acceptable for local testing, not for release builds. +RUNTIME_ARGS=() +if [[ -n "${APPIMAGETOOL_RUNTIME_FILE:-}" ]]; then + RUNTIME_ARGS=(--runtime-file "$APPIMAGETOOL_RUNTIME_FILE") +fi +APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$(uname -m)" appimagetool \ + "${RUNTIME_ARGS[@]}" \ + "$WORKDIR/squashfs-root" "$APPIMAGE_ABS" + +# Re-sign after repack so the updater can verify the artifact. +# Tauri 2.11 with createUpdaterArtifacts=true produces two possible formats: +# New: .AppImage + .AppImage.sig (sign the AppImage directly) +# Old: .AppImage.tar.gz + .tar.gz.sig (tar-wrapped, then signed) +# We handle both: always re-sign the AppImage; if a .tar.gz sibling exists +# alongside it, recreate it from the freshly repacked AppImage and re-sign that. +# Our release config pins createUpdaterArtifacts: true (build-release-config.mjs), +# so the tar.gz branch is dead in CI today — kept deliberately because the +# workflow's artifact-locate step prefers a tar.gz when one exists; dropping +# this branch could publish a stale tarball containing the unfixed AppImage. +if [[ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]]; then + # `tauri signer sign` reads TAURI_SIGNING_PRIVATE_KEY and + # TAURI_SIGNING_PRIVATE_KEY_PASSWORD from the environment (same as the + # macOS jobs in release.yml) — never pass the password via argv, where + # it would be visible in /proc//cmdline. + echo "==> Re-signing AppImage" + (cd "$DESKTOP_DIR" && pnpm tauri signer sign "$APPIMAGE_ABS") + + TARBALL="$APPIMAGE_ABS.tar.gz" + if [[ -f "$TARBALL" ]]; then + echo "==> Recreating updater archive $TARBALL" + tar -czf "$TARBALL" -C "$APPIMAGE_DIR" "$APPIMAGE_NAME" + echo "==> Re-signing updater archive" + (cd "$DESKTOP_DIR" && pnpm tauri signer sign "$TARBALL") + fi +else + echo "==> TAURI_SIGNING_PRIVATE_KEY not set — skipping signing (local build)" +fi + +echo "==> Done: $APPIMAGE_ABS" diff --git a/desktop/scripts/set-dmg-finder-text-size.sh b/desktop/scripts/set-dmg-finder-text-size.sh new file mode 100755 index 0000000000..b2e8012272 --- /dev/null +++ b/desktop/scripts/set-dmg-finder-text-size.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Patch Finder icon-label text size in a Tauri-generated DMG. +# +# Tauri's DMG config supports the background/layout fields we use, but it does +# not expose create-dmg's text-size option. Run this before DMG signing or +# notarization; mutating a signed DMG would invalidate the signature. + +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: $0 [text-size]" >&2 + exit 2 +fi + +DMG_PATH="$1" +TEXT_SIZE="${2:-14}" + +[[ -f "$DMG_PATH" ]] || { echo "Missing DMG: $DMG_PATH" >&2; exit 1; } + +WORK_DIR="$(mktemp -d -t buzz-dmg-text-size)" +MOUNT_POINT="$WORK_DIR/mount" +RW_DMG="$WORK_DIR/work.dmg" +OUT_DMG="$WORK_DIR/output.dmg" +MOUNTED="false" + +cleanup() { + if [[ "$MOUNTED" == "true" ]]; then + hdiutil detach "$MOUNT_POINT" >/dev/null 2>&1 || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +mkdir -p "$MOUNT_POINT" +hdiutil convert "$DMG_PATH" -format UDRW -o "$RW_DMG" -ov >/dev/null +hdiutil attach "$RW_DMG" -nobrowse -readwrite -noverify -mountpoint "$MOUNT_POINT" >/dev/null +MOUNTED="true" + +node - "$MOUNT_POINT/.DS_Store" "$TEXT_SIZE" <<'NODE' +const fs = require("node:fs"); + +const dsStorePath = process.argv[2]; +const textSize = Number(process.argv[3]); + +if (!Number.isFinite(textSize)) { + throw new Error(`Invalid DMG Finder text size: ${process.argv[3]}`); +} + +const data = fs.readFileSync(dsStorePath); +if (!data.includes(Buffer.from("textSize"))) { + throw new Error(`${dsStorePath} does not contain Finder textSize metadata`); +} + +const current = Buffer.alloc(8); +current.writeDoubleBE(16.0, 0); +const replacement = Buffer.alloc(8); +replacement.writeDoubleBE(textSize, 0); + +const positions = []; +let offset = 0; +while ((offset = data.indexOf(current, offset)) !== -1) { + positions.push(offset); + offset += 1; +} + +if (positions.length !== 1) { + throw new Error( + `Expected one Finder textSize value in ${dsStorePath}, found ${positions.length}`, + ); +} + +replacement.copy(data, positions[0]); +fs.writeFileSync(dsStorePath, data); +console.log(`Set DMG Finder textSize to ${textSize}`); +NODE + +sync +hdiutil detach "$MOUNT_POINT" >/dev/null +MOUNTED="false" + +hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -o "$OUT_DMG" -ov >/dev/null +mv "$OUT_DMG" "$DMG_PATH" diff --git a/desktop/scripts/summarize-flaky-tests.mjs b/desktop/scripts/summarize-flaky-tests.mjs new file mode 100644 index 0000000000..4cf17f1460 --- /dev/null +++ b/desktop/scripts/summarize-flaky-tests.mjs @@ -0,0 +1,71 @@ +import { appendFile, readFile } from "node:fs/promises"; + +// Playwright's `retries: 2` (desktop/playwright.config.ts) lets a test fail +// then pass on retry with no durable signal beyond a one-line "N flaky" in +// the console log — the exact gap that hid the stream.spec.ts membership +// race (#1798) for months. This walks the JSON reporter's suite tree +// (recursive: `describe` blocks nest as child `suites`) and appends any +// `status === "flaky"` test to the job's GitHub Actions summary so retried +// failures stay visible even when the shard ultimately goes green. +// +// Usage: node scripts/summarize-flaky-tests.mjs + +function collectFlakyTests(suite, out) { + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + if (test.status !== "flaky") continue; + out.push({ + title: `${suite.file} › ${spec.title}`, + project: test.projectName, + attempts: test.results?.length ?? 0, + }); + } + } + for (const child of suite.suites ?? []) { + collectFlakyTests(child, out); + } +} + +const [reportPath, runLabel] = process.argv.slice(2); +if (!reportPath || !runLabel) { + console.error( + "Usage: node scripts/summarize-flaky-tests.mjs ", + ); + process.exit(1); +} + +// This step runs `if: !cancelled()` purely to surface flaky tests — it must +// never fail the job on its own, so a malformed/unexpected report (from a +// Playwright version bump or a crashed run) is swallowed, not thrown. +try { + const report = JSON.parse(await readFile(reportPath, "utf8")); + + const flaky = []; + for (const suite of report.suites ?? []) { + collectFlakyTests(suite, flaky); + } + + if (flaky.length > 0) { + const escapeCell = (value) => String(value).replaceAll("|", "\\|"); + const rows = flaky + .map( + (t) => + `| ${escapeCell(t.title)} | ${escapeCell(t.project)} | ${t.attempts} |`, + ) + .join("\n"); + const summary = + `### Flaky tests — ${runLabel}\n\n` + + `${flaky.length} test(s) failed at least once before passing on retry:\n\n` + + "| Test | Project | Attempts |\n| --- | --- | --- |\n" + + `${rows}\n`; + + console.log(summary); + + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (summaryFile) { + await appendFile(summaryFile, `${summary}\n`); + } + } +} catch (error) { + console.log(`Skipping flaky-test summary: ${error.message}`); +} diff --git a/desktop/scripts/verify-macos-entitlements.sh b/desktop/scripts/verify-macos-entitlements.sh new file mode 100755 index 0000000000..33ee4a01de --- /dev/null +++ b/desktop/scripts/verify-macos-entitlements.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Fail a macOS release if the signing service dropped Buzz's entitlements. + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +APP_PATH="$1" +INFO_PLIST="$APP_PATH/Contents/Info.plist" + +[[ -d "$APP_PATH" ]] || { echo "Missing app bundle: $APP_PATH" >&2; exit 1; } +[[ -f "$INFO_PLIST" ]] || { echo "Missing app Info.plist: $INFO_PLIST" >&2; exit 1; } + +EXECUTABLE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$INFO_PLIST")" +EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" +[[ -f "$EXECUTABLE_PATH" ]] || { echo "Missing app executable: $EXECUTABLE_PATH" >&2; exit 1; } + +ENTITLEMENTS="$(mktemp -t buzz-entitlements)" +trap 'rm -f "$ENTITLEMENTS"' EXIT + +codesign --display --entitlements "$ENTITLEMENTS" --xml "$EXECUTABLE_PATH" 2>/dev/null +[[ -s "$ENTITLEMENTS" ]] || { + echo "Signed app has no embedded entitlements: $EXECUTABLE_PATH" >&2 + exit 1 +} + +required_entitlements=( + com.apple.security.device.audio-input + com.apple.security.device.camera + com.apple.security.cs.disable-library-validation +) + +for entitlement in "${required_entitlements[@]}"; do + value="$(/usr/libexec/PlistBuddy -c "Print :$entitlement" "$ENTITLEMENTS" 2>/dev/null || true)" + if [[ "$value" != "true" ]]; then + echo "Signed app is missing required entitlement: $entitlement" >&2 + exit 1 + fi +done + +echo "Verified required macOS entitlements on $EXECUTABLE_PATH" diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5189327c18..49f6c9939c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -190,10 +190,30 @@ dependencies = [ ] [[package]] -name = "arc-swap" -version = "1.9.1" +name = "arboard" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -218,9 +238,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-broadcast" @@ -418,6 +438,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-destructor" version = "0.3.0" @@ -508,9 +537,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -518,14 +547,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -647,15 +677,30 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -663,19 +708,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "bitcoin-io" -version = "0.1.100" +name = "bitcoin-consensus-encoding" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" +checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" +dependencies = [ + "hex-conservative 0.3.2", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] [[package]] name = "bitcoin_hashes" -version = "0.14.100" +version = "0.14.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.2", "serde", ] @@ -824,13 +890,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -847,7 +913,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", - "getrandom 0.4.2", + "getrandom 0.4.3", "hex", "nix 0.31.3", "reqwest 0.13.4", @@ -872,7 +938,7 @@ dependencies = [ "hmac 0.13.0", "nostr", "percent-encoding", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -885,9 +951,10 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.3.43" +version = "0.4.6" dependencies = [ "anyhow", + "arboard", "atomic-write-file", "audioadapter-buffers", "axum", @@ -902,13 +969,20 @@ dependencies = [ "ctrlc", "dirs", "earshot", + "ed25519-dalek", "futures-util", "hex", + "image", "infer", + "iroh", "keyring", "libc", + "mesh-llm-client", + "mesh-llm-events", "mesh-llm-host-runtime", + "mesh-llm-node", "mesh-llm-sdk", + "mesh-llm-system", "neteq", "nostr", "notify-rust", @@ -947,6 +1021,7 @@ dependencies = [ "toml 0.8.2", "url", "uuid", + "window-vibrancy", "windows-sys 0.61.2", "zeroize", "zip 8.6.0", @@ -974,6 +1049,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.0" @@ -987,10 +1068,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "bytes" -version = "1.11.1" +name = "byteorder-lite" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -1051,9 +1138,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" dependencies = [ "serde_core", ] @@ -1091,6 +1178,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1102,9 +1198,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.64" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -1170,9 +1266,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.3.0", @@ -1267,6 +1363,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.58" @@ -1291,6 +1396,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1316,6 +1427,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if 1.0.4", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1557,27 +1682,27 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -1595,6 +1720,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -1659,6 +1802,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -1668,7 +1821,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -1682,37 +1835,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - -[[package]] -name = "ctor" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" -dependencies = [ - "ctor-proc-macro", - "dtor 0.1.1", -] - [[package]] name = "ctor" version = "0.8.0" @@ -1720,7 +1842,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ "ctor-proc-macro", - "dtor 0.3.0", + "dtor", +] + +[[package]] +name = "ctor" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb22e947478ccf9dc44d8922042c677a63fbb88f2cb468521d1145816e5087cb" +dependencies = [ + "link-section", + "linktime-proc-macro", ] [[package]] @@ -1775,12 +1907,12 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "5.0.0-pre.6" +version = "5.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" dependencies = [ "cfg-if 1.0.4", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto 0.3.0", @@ -1905,9 +2037,9 @@ dependencies = [ [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", @@ -1940,10 +2072,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] -name = "der" -version = "0.8.0" +name = "deltae" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid", "pem-rfc7468", @@ -2130,13 +2268,22 @@ dependencies = [ "const-random", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser", "foldhash 0.2.0", "html5ever", @@ -2169,15 +2316,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - [[package]] name = "dtor" version = "0.3.0" @@ -2224,11 +2362,11 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "3.0.0-pre.7" +version = "3.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" dependencies = [ - "curve25519-dalek 5.0.0-pre.6", + "curve25519-dalek 5.0.0-rc.0", "ed25519", "rand_core 0.10.1", "serde", @@ -2246,9 +2384,9 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "embed-resource" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", @@ -2350,6 +2488,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -2389,12 +2542,34 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -2426,6 +2601,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2442,6 +2628,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2823,26 +3021,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if 1.0.4", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if 1.0.4", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] @@ -2856,6 +3050,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -3086,6 +3290,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if 1.0.4", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3107,6 +3322,17 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -3166,6 +3392,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + [[package]] name = "hf-hub" version = "1.0.0-rc.1" @@ -3194,9 +3429,9 @@ dependencies = [ [[package]] name = "hf-xet" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" +checksum = "2ba6e94f549dbe76ced2c56ada52958e63f9056afabb21f74aae1b5777c8cd5d" dependencies = [ "async-trait", "bytes", @@ -3206,6 +3441,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "uuid", "xet-client", @@ -3233,7 +3469,7 @@ dependencies = [ "idna", "ipnet", "jni 0.22.4", - "rand 0.10.1", + "rand 0.10.2", "rustls", "thiserror 2.0.18", "tinyvec", @@ -3255,7 +3491,7 @@ dependencies = [ "jni 0.22.4", "once_cell", "prefix-trie", - "rand 0.10.1", + "rand 0.10.2", "ring", "thiserror 2.0.18", "tinyvec", @@ -3280,7 +3516,7 @@ dependencies = [ "ndk-context", "once_cell", "parking_lot", - "rand 0.10.1", + "rand 0.10.2", "resolv-conf", "rustls", "smallvec", @@ -3375,15 +3611,15 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -3595,12 +3831,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -3658,12 +3888,41 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand 0.10.1", + "rand 0.10.2", "tokio", "url", "xmltree", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3687,6 +3946,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -3715,6 +3983,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "instant" version = "0.1.13" @@ -3751,9 +4032,9 @@ dependencies = [ [[package]] name = "iroh" -version = "1.0.0-rc.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef865dc2d11a19fe670ff217b68ffc3b511bddf473dc3a3e120090b9f691803" +checksum = "5fca9b4b462c343ff88fc0af4096c186f939b602a0bc08723536ef2c31c93971" dependencies = [ "backon", "blake3", @@ -3764,7 +4045,7 @@ dependencies = [ "derive_more", "ed25519-dalek", "futures-util", - "getrandom 0.4.2", + "getrandom 0.4.3", "hickory-resolver", "http", "ipnet", @@ -3783,12 +4064,11 @@ dependencies = [ "pin-project", "portable-atomic", "portmapper", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "rustc-hash", "rustls", "rustls-pki-types", - "rustls-webpki", "serde", "smallvec", "strum", @@ -3799,36 +4079,32 @@ dependencies = [ "tracing", "url", "wasm-bindgen-futures", - "webpki-roots 1.0.7", ] [[package]] name = "iroh-base" -version = "1.0.0-rc.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af93d67701c00c504982154569192ad384738c0450ba1196930314b955100552" +checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d" dependencies = [ - "curve25519-dalek 5.0.0-pre.6", + "curve25519-dalek 5.0.0-rc.0", "data-encoding", "data-encoding-macro", "derive_more", - "digest 0.11.3", "ed25519-dalek", - "getrandom 0.4.2", + "getrandom 0.4.3", "n0-error", - "rand 0.10.1", + "rand 0.10.2", "serde", - "sha2 0.11.0", "url", "zeroize", - "zeroize_derive", ] [[package]] name = "iroh-dns" -version = "1.0.0-rc.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de4112c91eb64094d77df9d3112606dcf7ff216421afccd2dc762fda5a7b2879" +checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" dependencies = [ "arc-swap", "cfg_aliases", @@ -3838,8 +4114,8 @@ dependencies = [ "n0-error", "n0-future", "ndk-context", - "rand 0.10.1", - "reqwest 0.13.4", + "portable-atomic", + "rand 0.10.2", "rustls", "simple-dns", "strum", @@ -3850,9 +4126,9 @@ dependencies = [ [[package]] name = "iroh-metrics" -version = "1.0.0-rc.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d102597d0ee523f17fdb672c532395e634dbe945429284c811430d63bacc0d8a" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" dependencies = [ "iroh-metrics-derive", "itoa", @@ -3877,16 +4153,16 @@ dependencies = [ [[package]] name = "iroh-relay" -version = "1.0.0-rc.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a70030b9e71c1183bd4f88fbdbebfa1af2a5be549dd6f20a1e8ac3cd0202ee9d" +checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" dependencies = [ "blake3", "bytes", "cfg_aliases", "data-encoding", "derive_more", - "getrandom 0.4.2", + "getrandom 0.4.3", "hickory-resolver", "http", "http-body-util", @@ -3895,7 +4171,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.0", + "lru 0.18.1", "n0-error", "n0-future", "noq", @@ -3903,7 +4179,7 @@ dependencies = [ "num_enum", "pin-project", "postcard", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "rustls", "rustls-pki-types", @@ -3917,7 +4193,7 @@ dependencies = [ "tracing", "url", "vergen-gitcl", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", "ws_stream_wasm", ] @@ -4060,19 +4336,19 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if 1.0.4", "futures-util", @@ -4111,6 +4387,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -4156,18 +4443,18 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libappindicator" version = "0.9.0" @@ -4242,9 +4529,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -4260,6 +4547,27 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "link-section" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e333fe507b738576d6da5bb3f1a7d7a1c80307ed9ef31624c057d844c19c93e9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4278,6 +4586,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -4289,9 +4603,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loom" @@ -4314,9 +4628,9 @@ checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" [[package]] name = "lru" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" dependencies = [ "hashbrown 0.17.1", ] @@ -4338,9 +4652,9 @@ dependencies = [ [[package]] name = "lzma-rust2" -version = "0.16.4" +version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce716bf1a316f47a280fc76295f6495b5bea4752bca01c3b3885e101b1c23c02" +checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" dependencies = [ "sha2 0.11.0", ] @@ -4365,6 +4679,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "mach2" version = "0.5.0" @@ -4417,9 +4741,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" [[package]] name = "memoffset" @@ -4432,8 +4762,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "hex", "mesh-llm-client", @@ -4442,8 +4772,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4451,10 +4781,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "mesh-llm-build-info" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" + [[package]] name = "mesh-llm-client" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "async-trait", @@ -4472,7 +4807,7 @@ dependencies = [ "model-artifact", "nostr-sdk", "prost", - "rand 0.10.1", + "rand 0.10.2", "rustls", "serde", "serde_json", @@ -4485,8 +4820,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "dirs", @@ -4496,12 +4831,13 @@ dependencies = [ "skippy-protocol", "toml 0.9.12+spec-1.1.0", "toml_edit 0.25.12+spec-1.1.0", + "url", ] [[package]] name = "mesh-llm-embedded-runtime" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -4510,18 +4846,20 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "clap", + "crossterm 0.28.1", + "ratatui", "serde_json", ] [[package]] name = "mesh-llm-gpu-bench" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "cc", @@ -4533,8 +4871,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "serde", "serde_json", @@ -4542,16 +4880,16 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "mesh-llm-native-runtime", ] [[package]] name = "mesh-llm-host-runtime" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "argon2", @@ -4562,7 +4900,7 @@ dependencies = [ "chacha20poly1305", "chrono", "clap", - "crossterm", + "crossterm 0.28.1", "crypto_box", "dirs", "ed25519-dalek", @@ -4573,12 +4911,14 @@ dependencies = [ "http", "http-body-util", "httparse", + "if-addrs", "iroh", "json5", "keyring", "libc", "mdns-sd", "mesh-llm-api-server", + "mesh-llm-build-info", "mesh-llm-client", "mesh-llm-config", "mesh-llm-events", @@ -4606,7 +4946,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "prost", - "rand 0.10.1", + "rand 0.10.2", "regex-lite", "reqwest 0.12.28", "rmcp", @@ -4623,6 +4963,7 @@ dependencies = [ "skippy-runtime", "skippy-server", "skippy-topology", + "socket2", "tabwriter", "tar", "tempfile", @@ -4640,8 +4981,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "argon2", "base64 0.22.1", @@ -4652,7 +4993,7 @@ dependencies = [ "ed25519-dalek", "hex", "keyring", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.10.9", @@ -4662,8 +5003,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "serde", @@ -4673,8 +5014,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "mesh-llm-types", @@ -4687,8 +5028,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "async-trait", @@ -4704,8 +5045,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "dirs", @@ -4722,8 +5063,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "hex", @@ -4735,22 +5076,23 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "dirs", "flate2", "futures-util", "hex", + "mesh-llm-build-info", "mesh-llm-hardware-profile", "mesh-llm-native-runtime", "reqwest 0.12.28", @@ -4765,8 +5107,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4780,8 +5122,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "dirs", @@ -4791,8 +5133,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "chrono", @@ -4800,6 +5142,7 @@ dependencies = [ "dirs", "hex", "libc", + "mesh-llm-build-info", "mesh-llm-gpu-bench", "reqwest 0.12.28", "semver", @@ -4813,8 +5156,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "hex", "serde", @@ -4824,13 +5167,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" [[package]] name = "mesh-mixture-of-agents" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -4857,6 +5200,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.5" @@ -4887,8 +5236,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "async-trait", @@ -4898,8 +5247,8 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "async-trait", @@ -4916,8 +5265,8 @@ dependencies = [ [[package]] name = "model-package" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "bytes", @@ -4936,16 +5285,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "model-artifact", @@ -4978,10 +5327,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" [[package]] -name = "muda" -version = "0.19.2" +name = "moxcms" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -5006,9 +5365,9 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "n0-error" -version = "1.0.0-rc.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" dependencies = [ "n0-error-macros", "spez", @@ -5048,9 +5407,9 @@ dependencies = [ [[package]] name = "n0-watcher" -version = "1.0.0-rc.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928d8039a66cce5efcfd35e88b32d3defc8eba630b3ac451522997f563956a52" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" dependencies = [ "derive_more", "n0-error", @@ -5112,19 +5471,22 @@ checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" [[package]] name = "netdev" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" dependencies = [ "block2", "dispatch2", "dlopen2", "ipnet", + "jni 0.21.1", "libc", "mac-addr", + "ndk-context", "netlink-packet-core", - "netlink-packet-route 0.29.0", + "netlink-packet-route", "netlink-sys", + "objc2", "objc2-core-foundation", "objc2-core-wlan", "objc2-foundation", @@ -5158,21 +5520,9 @@ dependencies = [ [[package]] name = "netlink-packet-route" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" -dependencies = [ - "bitflags 2.13.0", - "libc", - "log", - "netlink-packet-core", -] - -[[package]] -name = "netlink-packet-route" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" dependencies = [ "bitflags 2.13.0", "libc", @@ -5209,14 +5559,15 @@ dependencies = [ [[package]] name = "netwatch" -version = "0.18.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2071e0c2b5b229622c459096b84f1ad51afa150cdeeefdad491ef3704e581d91" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" dependencies = [ "atomic-waker", "bytes", "cfg_aliases", "derive_more", + "ipnet", "js-sys", "libc", "n0-error", @@ -5224,7 +5575,7 @@ dependencies = [ "n0-watcher", "netdev", "netlink-packet-core", - "netlink-packet-route 0.30.0", + "netlink-packet-route", "netlink-proto", "netlink-sys", "noq-udp", @@ -5287,10 +5638,20 @@ dependencies = [ ] [[package]] -name = "noq" -version = "1.0.0-rc.1" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198b99fc085a5db1f7d259edb5ede8311e59f28cdd2687920b4313613d21a73f" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noq" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" dependencies = [ "bytes", "cfg_aliases", @@ -5310,18 +5671,18 @@ dependencies = [ [[package]] name = "noq-proto" -version = "1.0.0-rc.1" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ab0ac774795ce1e42a7e61266e71f3be8110210630441169ac8dda403dd23f1" +checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" dependencies = [ "aes-gcm", "bytes", "derive_more", "enum-assoc", - "getrandom 0.4.2", + "getrandom 0.4.3", "identity-hash", "lru-slab", - "rand 0.10.1", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -5337,9 +5698,9 @@ dependencies = [ [[package]] name = "noq-udp" -version = "1.0.0-rc.1" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3c1520eacd33fd6b009e2e70116b05508ade51db5e0d315ff8bf6b702148c2b" +checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" dependencies = [ "cfg_aliases", "libc", @@ -5350,9 +5711,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.3" +version = "0.44.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" +checksum = "98cf5d15d70d1f8f4059e5f79923ac15891eb691d2843d01191e0585fb064d70" dependencies = [ "base64 0.22.1", "bech32", @@ -5427,16 +5788,16 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.17.0" +version = "4.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" dependencies = [ "futures-lite", "log", "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus 5.16.0", + "zbus 5.17.0", ] [[package]] @@ -5473,9 +5834,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -5518,11 +5879,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -5564,7 +5924,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.118", @@ -5599,6 +5959,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -5926,20 +6287,19 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.5" +version = "5.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" dependencies = [ "dunce", "is-wsl", "libc", - "pathdiff", ] [[package]] name = "openai-frontend" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "async-trait", "axum", @@ -6096,6 +6456,15 @@ dependencies = [ "audiopus_sys", ] +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -6139,6 +6508,30 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "pango" version = "0.18.3" @@ -6267,13 +6660,55 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.5", "indexmap 2.14.0", ] @@ -6288,25 +6723,55 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", ] [[package]] @@ -6316,7 +6781,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -6325,13 +6803,22 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.118", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -6396,13 +6883,13 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.39.4", + "quick-xml", "serde", "time", ] @@ -6481,9 +6968,9 @@ dependencies = [ [[package]] name = "portmapper" -version = "0.18.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64959cbabf952c8ffcbaea13745308508f1f825922f4068353f3de08d42cf214" +checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" dependencies = [ "base64 0.22.1", "bytes", @@ -6496,7 +6983,7 @@ dependencies = [ "n0-future", "netwatch", "num_enum", - "rand 0.10.1", + "rand 0.10.2", "serde", "smallvec", "socket2", @@ -6805,28 +7292,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" [[package]] -name = "quick-xml" -version = "0.37.5" +name = "pxfm" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", -] +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -6844,15 +7334,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -6866,23 +7357,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -6922,12 +7413,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.2", + "chacha20 0.10.1", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -6982,7 +7473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand 0.10.1", + "rand 0.10.2", ] [[package]] @@ -7003,6 +7494,107 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.0", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru 0.18.1", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if 1.0.4", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -7069,9 +7661,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -7081,9 +7673,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -7260,9 +7852,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", "base64 0.22.1", @@ -7275,7 +7867,7 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", @@ -7293,9 +7885,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -7313,7 +7905,7 @@ dependencies = [ "cpal", "dasp_sample", "num-rational", - "rand 0.10.1", + "rand 0.10.2", "rand_distr", "rtrb", "symphonia", @@ -7378,9 +7970,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -7433,9 +8025,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "log", @@ -7461,9 +8053,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -7510,9 +8102,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -7738,8 +8330,8 @@ dependencies = [ "derive_more", "log", "new_debug_unreachable", - "phf", - "phf_codegen", + "phf 0.13.1", + "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash", "servo_arc", @@ -8058,9 +8650,9 @@ dependencies = [ [[package]] name = "sherpa-onnx" -version = "1.13.3" +version = "1.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98baa63be165cc1bccd418c210269c68bcdd0c279195ecce0140645bc6e520f4" +checksum = "0b142d3f255cb4e4b7808ea25869db6f5714e0a3550da355234483b4db552055" dependencies = [ "serde", "serde_json", @@ -8069,9 +8661,9 @@ dependencies = [ [[package]] name = "sherpa-onnx-sys" -version = "1.13.3" +version = "1.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21ac2c3342f826069bbf0be3578f9601f3802a1bff50bbced8230a15ec9eed8f" +checksum = "ffc951af03dc0653c0622158ca8a585a6f2bc43b7b06048cf0e5b5020005c227" dependencies = [ "bzip2 0.4.4", "tar", @@ -8160,8 +8752,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "blake3", @@ -8170,29 +8762,29 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "libloading 0.8.9", ] [[package]] name = "skippy-metrics" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" [[package]] name = "skippy-protocol" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "prost", "prost-build", @@ -8202,8 +8794,8 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "libc", @@ -8216,8 +8808,8 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "anyhow", "async-trait", @@ -8244,8 +8836,8 @@ dependencies = [ [[package]] name = "skippy-topology" -version = "0.68.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82#ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82" +version = "0.73.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ "serde", "serde_json", @@ -8376,9 +8968,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" dependencies = [ "bytes", "futures-util", @@ -8423,7 +9015,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -8433,8 +9025,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -8652,6 +9244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -8819,9 +9412,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.2" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -8870,9 +9463,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -8891,9 +9484,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -8918,9 +9511,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -8932,9 +9525,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -9062,7 +9655,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows 0.61.3", - "zbus 5.16.0", + "zbus 5.17.0", ] [[package]] @@ -9088,7 +9681,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus 5.16.0", + "zbus 5.17.0", ] [[package]] @@ -9161,9 +9754,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.2" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -9186,9 +9779,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.2" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -9212,9 +9805,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", @@ -9228,7 +9821,7 @@ dependencies = [ "json-patch", "log", "memchr", - "phf", + "phf 0.13.1", "plist", "proc-macro2", "quote", @@ -9261,11 +9854,10 @@ dependencies = [ [[package]] name = "tauri-winrt-notification" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "quick-xml 0.37.5", "thiserror 2.0.18", "windows 0.61.3", "windows-version", @@ -9278,7 +9870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -9286,12 +9878,87 @@ dependencies = [ [[package]] name = "tendril" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ "new_debug_unreachable", - "utf-8", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", ] [[package]] @@ -9344,10 +10011,24 @@ dependencies = [ ] [[package]] -name = "time" -version = "0.3.49" +name = "tiff" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "js-sys", @@ -9368,9 +10049,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.29" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -9455,7 +10136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" dependencies = [ "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "tokio", ] @@ -9557,18 +10238,18 @@ dependencies = [ [[package]] name = "tokio-websockets" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" dependencies = [ "base64 0.22.1", "bytes", "futures-core", "futures-sink", - "getrandom 0.4.2", + "getrandom 0.4.3", "http", "httparse", - "rand 0.10.1", + "rand 0.10.2", "ring", "rustls-pki-types", "sha1_smol", @@ -9578,6 +10259,30 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.118", +] + [[package]] name = "toml" version = "0.8.2" @@ -9888,9 +10593,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.23.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", "dirs", @@ -10091,6 +10796,17 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" @@ -10192,11 +10908,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "atomic", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -10303,6 +11020,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -10343,16 +11069,7 @@ version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -10366,9 +11083,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if 1.0.4", "once_cell", @@ -10379,9 +11096,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -10389,9 +11106,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10399,9 +11116,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -10412,35 +11129,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -10467,23 +11162,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -10501,12 +11184,12 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ - "phf", - "phf_codegen", + "phf 0.13.1", + "phf_codegen 0.13.1", "string_cache", "string_cache_codegen", ] @@ -10573,9 +11256,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -10586,14 +11269,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -10634,6 +11317,84 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "whoami" version = "2.1.2" @@ -11203,100 +11964,12 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.118", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.118", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "wmi" version = "0.18.4" @@ -11441,22 +12114,21 @@ dependencies = [ [[package]] name = "xet-client" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" +checksum = "b45fc10a88b05d4824d7fc0f97d04c13eaea98da5e8032194b8a0cbdab942090" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bytes", - "clap", "crc32fast", "futures", "http", "hyper", "lazy_static", "more-asserts", - "rand 0.10.1", + "rand 0.10.2", "redb", "reqwest 0.13.4", "reqwest-middleware", @@ -11468,8 +12140,8 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-retry", + "tokio_with_wasm", "tracing", - "tracing-subscriber", "url", "urlencoding", "web-time", @@ -11479,32 +12151,29 @@ dependencies = [ [[package]] name = "xet-core-structures" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" +checksum = "eb39bc5e0478e8f03e15f08b0a396f197fcfa2ef5027da666fd100c806bf0ea3" dependencies = [ "async-trait", "base64 0.22.1", "blake3", "bytemuck", "bytes", - "clap", "countio", - "csv", "futures", "futures-util", - "getrandom 0.4.2", + "getrandom 0.4.3", "heapify", "itertools", "lazy_static", "lz4_flex", "more-asserts", - "rand 0.10.1", + "rand 0.10.2", "regex", "safe-transmute", "serde", "static_assertions", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", @@ -11516,21 +12185,20 @@ dependencies = [ [[package]] name = "xet-data" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fd409bef621411a9d9013798540bb8036cb2678f03ab39af89a5e88034ed8c" +checksum = "e428a3b9667c1d0d335a4c77407c04d3a3e6c4f228aee364a1b56ed94cf861ca" dependencies = [ "anyhow", "async-trait", "bytes", "chrono", - "clap", "gearhash", "http", "itertools", "lazy_static", "more-asserts", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.10.9", @@ -11538,10 +12206,11 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "url", "uuid", - "walkdir", + "web-time", "xet-client", "xet-core-structures", "xet-runtime", @@ -11549,9 +12218,9 @@ dependencies = [ [[package]] name = "xet-runtime" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d8f121c33866f7648b737abe70d0e2dd9c0af4ffdd7219207531d0283aa63d" +checksum = "e8e1f8cfa256029d1bba7860ca12bfd145f17aa7aede5d968e9c304047656be1" dependencies = [ "anyhow", "async-trait", @@ -11559,7 +12228,7 @@ dependencies = [ "chrono", "colored", "const-str", - "ctor 0.6.3", + "ctor 1.0.8", "dirs", "futures", "git-version", @@ -11570,7 +12239,7 @@ dependencies = [ "more-asserts", "oneshot", "pin-project", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.13.4", "serde", "serde_json", @@ -11579,9 +12248,11 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "tracing-appender", "tracing-subscriber", + "web-time", "whoami", "winapi", ] @@ -11664,9 +12335,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.16.0" +version = "5.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" dependencies = [ "async-broadcast", "async-executor", @@ -11692,9 +12363,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.3", - "zbus_macros 5.16.0", - "zbus_names 4.3.2", - "zvariant 5.12.0", + "zbus_macros 5.17.0", + "zbus_names 4.3.3", + "zvariant 5.13.0", ] [[package]] @@ -11712,17 +12383,17 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.16.0" +version = "5.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", - "zbus_names 4.3.2", - "zvariant 5.12.0", - "zvariant_utils 3.4.0", + "zbus_names 4.3.3", + "zvariant 5.13.0", + "zvariant_utils 3.5.0", ] [[package]] @@ -11738,29 +12409,29 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" dependencies = [ "serde", "winnow 1.0.3", - "zvariant 5.12.0", + "zvariant 5.13.0", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -11882,7 +12553,7 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "getrandom 0.4.2", + "getrandom 0.4.3", "hmac 0.13.0", "indexmap 2.14.0", "lzma-rust2", @@ -11899,9 +12570,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" @@ -11949,6 +12620,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "4.2.0" @@ -11964,16 +12650,16 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.12.0" +version = "5.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.3", - "zvariant_derive 5.12.0", - "zvariant_utils 3.4.0", + "zvariant_derive 5.13.0", + "zvariant_utils 3.5.0", ] [[package]] @@ -11991,15 +12677,15 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.12.0" +version = "5.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", - "zvariant_utils 3.4.0", + "zvariant_utils 3.5.0", ] [[package]] @@ -12015,9 +12701,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 7ad214e09f..8d2539c18c 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.3.43" +version = "0.4.6" description = "Buzz desktop app" authors = ["you"] edition = "2021" @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] [features] default = ["system-keyring"] -mesh-llm = ["dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime"] +mesh-llm = ["dep:iroh", "dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime", "dep:mesh-llm-client", "dep:mesh-llm-node", "dep:mesh-llm-system", "dep:mesh-llm-events"] # OS keyring backing for desktop secret storage (nsec private keys). When # disabled, secrets fall back to 0o600 files. On by default for real builds. system-keyring = ["dep:keyring"] @@ -45,16 +45,17 @@ notify-rust = "4" objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } +window-vibrancy = "0.6" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } [dependencies] atomic-write-file = "0.3" anyhow = "1" dirs = "6" -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["macos-private-api"] } tauri-plugin-deep-link = "2" tauri-plugin-opener = "2" tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } @@ -65,6 +66,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" infer = "0.19" hex = "0.4" +ed25519-dalek = "=3.0.0-rc.0" tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time"] } tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } tokio-util = { version = "0.7", features = ["rt"] } @@ -84,8 +86,15 @@ buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" } buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +iroh = { version = "1.0.2", optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +# Model catalog + hardware survey for the Share-compute model picker (same +# diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" @@ -95,6 +104,8 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2.3.3" uuid = { version = "1", features = ["v4"] } png = "0.18" +arboard = "3" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] } zip = "8" sherpa-onnx = "1.12" regex = "1" diff --git a/desktop/src-tauri/Entitlements.plist b/desktop/src-tauri/Entitlements.plist index 73bf832c7b..294ba52362 100644 --- a/desktop/src-tauri/Entitlements.plist +++ b/desktop/src-tauri/Entitlements.plist @@ -6,5 +6,8 @@ com.apple.security.device.camera + + com.apple.security.cs.disable-library-validation + diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 3085493a30..c4d7b71f49 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -13,6 +13,8 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { @@ -72,6 +74,21 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}"); } + // Presence-only flag: when set (any non-empty value), observer-feed archive + // defaults to ON for the current identity on first run. OSS builds leave + // this unset → default OFF. No JSON validation needed — the command only + // checks `.is_some()`. + if std::env::var("BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT=1"); + } + + // Presence-only flag: when set (any non-empty value), agent-turn-metric + // archive defaults to ON for the current identity on first run. OSS builds + // leave this unset → default OFF. + if std::env::var("BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT=1"); + } + let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY") .ok() .map(|value| value.trim().to_string()) @@ -85,5 +102,20 @@ fn main() { println!("cargo:rustc-cfg=buzz_updater_enabled"); } + // Cargo test executables get no embedded Windows manifest (tauri_build + // attaches one to bin targets only), so the loader binds comctl32 v5, which + // lacks TaskDialogIndirect (statically imported via tauri-plugin-dialog/rfd) + // and debug test exes die at load with STATUS_ENTRYPOINT_NOT_FOUND. Declaring + // the Common Controls v6 dependency makes link.exe emit a side-by-side + // .manifest that the loader honors for manifest-less executables; + // binaries with an embedded manifest (the real app) ignore it. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") + { + println!( + "cargo:rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'" + ); + } + tauri_build::build() } diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 57e0e87f49..8835b29dec 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -21,7 +21,8 @@ "window-state:default", "dialog:default", "updater:allow-check", - "updater:allow-download-and-install", + "updater:allow-download", + "updater:allow-install", "process:allow-restart", "global-shortcut:allow-register", "global-shortcut:allow-unregister", diff --git a/desktop/src-tauri/icons/dmg-background.png b/desktop/src-tauri/icons/dmg-background.png index e1f619b498..da197f6ebb 100644 Binary files a/desktop/src-tauri/icons/dmg-background.png and b/desktop/src-tauri/icons/dmg-background.png differ diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index dca627fe6e..7cff8e534a 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -1,7 +1,10 @@ use std::{ collections::HashMap, io::Write, - sync::{atomic::AtomicU16, Arc, Mutex}, + sync::{ + atomic::{AtomicBool, AtomicU16}, + Arc, Mutex, + }, }; use nostr::{Keys, ToBech32}; @@ -18,6 +21,15 @@ pub struct AppState { /// Workspace-provided relay URL override. Set by `apply_workspace` on app /// init and takes priority over env vars and compile-time defaults. pub relay_url_override: Mutex>, + /// Set during backend setup when managed agents are eligible for launch + /// restore. `apply_workspace` consumes it after installing the workspace + /// relay and identity, so agents never start against the fallback relay. + pub managed_agent_restore_pending: AtomicBool, + /// Shared shutdown signal checked by launch-time agent restoration. + pub shutdown_started: AtomicBool, + /// Serializes the restore spawn/register transition with shutdown cleanup, + /// preventing an agent from spawning after shutdown has swept processes. + pub managed_agent_restore_transition: Mutex<()>, pub managed_agents_store_lock: Mutex<()>, pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, @@ -33,6 +45,46 @@ pub struct AppState { pub audio_output_device: Mutex>, /// Port of the localhost media streaming proxy (set during setup). pub media_proxy_port: AtomicU16, + /// Set when identity resolution detected a "keyring-locked" state: the + /// keyring is unreachable this boot but a migration marker shows the key + /// lives there. An ephemeral key is generated so the app can open; all + /// signing commands check this flag via [`AppState::signing_keys`] and + /// return `Err` so no events are published under the inaccessible identity. + /// Mutually exclusive with `identity_lost` (guaranteed by `RecoveryState` + /// at the resolve boundary). + /// + /// Ordering: writers store with `Ordering::Release` after `state.keys` is + /// updated, so a reader observing `false` with `Ordering::Acquire` is + /// guaranteed to see the updated keys. Writers: `setup()` (initial + /// resolution via `resolve_persisted_identity`) and `import_identity` + /// (clears the flag when the user successfully imports a new key). + pub keyring_locked: AtomicBool, + /// Set when identity resolution detected a "lost" state: the migration + /// marker was present but the keyring was empty and no plaintext fallback + /// existed. An ephemeral key was generated to let the app boot; the + /// frontend checks this flag via `get_identity` and routes to the nsec + /// re-import step instead of the normal onboarding profile flow. + /// + /// Ordering: writers store with `Ordering::Release` after `state.keys` is + /// updated, so a reader observing `false` with `Ordering::Acquire` is + /// guaranteed to see the updated keys. Writers: `setup()` (initial + /// resolution) and `import_identity`/`persist_current_identity` + /// (user-initiated key import). + pub identity_lost: AtomicBool, + /// Serializes runtime identity mutations (`import_identity` and + /// `persist_current_identity`) so a stale ephemeral key can never overwrite + /// a newer imported key during concurrent calls. Deliberately separate from + /// `keys` so readers (signing, get_identity, etc.) are not blocked during + /// keyring I/O. + pub identity_mutation: Mutex<()>, + /// Set when the boot-time Phase 2 reset attempted a wipe but verification + /// failed. The sentinel is preserved so the next relaunch retries. All + /// identity-dependent setup is skipped; the frontend shows a reset-failed + /// recovery screen via `get_identity`. + /// + /// Ordering: written once in `setup()` with `Ordering::Release`; read in + /// `get_identity` with `Ordering::Acquire`. + pub reset_failed: AtomicBool, /// Cached ACP session config from running agents, keyed by agent pubkey. /// Populated when the harness emits `session_config_captured` observer events. pub session_config_cache: Mutex>, @@ -41,11 +93,26 @@ pub struct AppState { /// In-process mesh-llm node started by Buzz Desktop. #[cfg(feature = "mesh-llm")] pub mesh_llm_runtime: AsyncMutex>, - /// Runtime-owned relay-mesh control plane (call-me-now listener + connect - /// request publish/retry). Installed once at identity-set time so the - /// listener is up before any restore/create can request a connection. + /// Runtime-owned shared-compute coordinator. It publishes member-signed + /// discovery status and reconciles MeshLLM's admission roster; MeshLLM + /// itself owns direct QUIC/iroh connection establishment. #[cfg(feature = "mesh-llm")] pub mesh_coordinator: AsyncMutex>, + /// `(creator_pubkey_hex, channel_id)` pairs for channels the *named* + /// identity created via `create_channel` and has not yet observed its own + /// kind:39002 membership entry for. The relay provisions that entry + /// asynchronously (#1761), so without this overlay a freshly created + /// channel's owner reads back as `is_member=false` until the snapshot + /// propagates, disabling their own composer. Entries are bound to the + /// creating identity so an in-process identity swap (`import_identity`, + /// workspace apply) can never inherit another identity's stale + /// membership. Populated only by this process's own `create_channel` + /// calls — a relay can never write into it — so it carries no + /// trust-boundary risk. `get_channels` clears an entry once the real + /// kind:39002 is observed for the current identity, keeping the set + /// bounded and letting a later leave correctly flip the channel back to + /// `is_member=false`. + pub pending_owned_channels: Mutex>, } /// Parse the `BUZZ_PRIVATE_KEY` env var into identity keys. `Some` means the @@ -93,6 +160,10 @@ pub fn build_app_state() -> AppState { .build() .unwrap_or_else(|_| reqwest::Client::new()), relay_url_override: Mutex::new(None), + managed_agent_restore_pending: AtomicBool::new(false), + shutdown_started: AtomicBool::new(false), + managed_agent_restore_transition: Mutex::new(()), + identity_mutation: Mutex::new(()), managed_agents_store_lock: Mutex::new(()), channel_templates_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), @@ -104,10 +175,14 @@ pub fn build_app_state() -> AppState { prevent_sleep: Arc::new(Mutex::new( crate::prevent_sleep::PreventSleepState::default(), )), + keyring_locked: AtomicBool::new(false), + identity_lost: AtomicBool::new(false), + reset_failed: AtomicBool::new(false), #[cfg(feature = "mesh-llm")] mesh_llm_runtime: AsyncMutex::new(None), #[cfg(feature = "mesh-llm")] mesh_coordinator: AsyncMutex::new(None), + pending_owned_channels: Mutex::new(std::collections::HashSet::new()), } } @@ -137,6 +212,59 @@ impl AppState { } } + /// Record that `channel_id` was just created by `creator_pubkey` and its + /// kind:39002 owner membership has not yet been observed. + pub fn mark_pending_owned_channel(&self, creator_pubkey: &str, channel_id: &str) { + if let Ok(mut set) = self.pending_owned_channels.lock() { + set.insert((creator_pubkey.to_string(), channel_id.to_string())); + } + } + + /// Whether `channel_id` is still awaiting `my_pubkey`'s kind:39002 entry. + /// Bound to `my_pubkey` so an in-process identity swap never inherits + /// another identity's pending-owner entry for the same channel id. + pub fn is_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) -> bool { + self.pending_owned_channels + .lock() + .map(|set| set.contains(&(my_pubkey.to_string(), channel_id.to_string()))) + .unwrap_or(false) + } + + /// Drop the `(my_pubkey, channel_id)` entry from the pending-owner + /// overlay once that identity's real kind:39002 membership has been + /// observed. + pub fn clear_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) { + if let Ok(mut set) = self.pending_owned_channels.lock() { + set.remove(&(my_pubkey.to_string(), channel_id.to_string())); + } + } + + /// Return the active identity keys if they are in a signable state. + /// + /// Returns `Err` when the identity is in a lost state (`identity_lost` + /// — ephemeral key, user must re-import their nsec) or when the keyring + /// is locked (`keyring_locked` — key is held in a keyring that is + /// unavailable this boot). All signing and publish commands must call + /// this instead of locking `state.keys` directly, so that recovery mode + /// blocks publishing under an invalid or inaccessible identity. + pub fn signing_keys(&self) -> Result { + if self + .identity_lost + .load(std::sync::atomic::Ordering::Acquire) + || self + .keyring_locked + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("identity is in recovery mode; event signing is disabled \ + until the identity is restored and Buzz is relaunched" + .to_string()); + } + self.keys + .lock() + .map_err(|e| e.to_string()) + .map(|k| k.clone()) + } + /// Emit the current huddle state to the frontend via Tauri event. /// /// Acquires both locks (app_handle + huddle_state), clones a snapshot, @@ -156,13 +284,21 @@ impl AppState { } } -/// Resolve the user's identity key from the app data directory. +/// Resolve the user's identity key from the app data directory and wire +/// the resulting [`RecoveryState`] into `AppState`. /// /// Priority: `BUZZ_PRIVATE_KEY` env var (already handled in `build_app_state`) -/// → `{app_data_dir}/identity.key` file → generate + save. +/// → keyring → `{app_data_dir}/identity.key` file → generate + save. /// -/// Writes use `atomic-write-file` which handles temp file creation, fsync, -/// atomic rename, and directory sync — no partial or corrupt files on disk. +/// On success, writes the resolved keys into `state.keys` (with the mutex) +/// before storing the recovery flags (Release), so any thread that reads +/// either flag as `false` with Acquire is guaranteed to see the updated keys. +/// +/// Sets `state.identity_lost` on `RecoveryState::Lost` (keyring empty after +/// migration — key gone externally) and `state.keyring_locked` on +/// `RecoveryState::KeyringLocked` (keyring unreachable — key still in keyring +/// but inaccessible this boot). Both states boot with an ephemeral key; the +/// frontend shows different recovery screens for each. pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(), String> { // Only skip file-based resolution if the env var was present AND parsed // successfully. A malformed env var should fall through to the persisted @@ -177,14 +313,33 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<( .map_err(|e| format!("app data dir: {e}"))?; std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; - let keys = load_or_create_identity(&data_dir)?; - *state.keys.lock().map_err(|e| e.to_string())? = keys; + let resolved = load_or_create_identity(&data_dir)?; + // Write keys before setting the recovery flags (Release) so any thread + // that reads a flag as false with Acquire is guaranteed to see the keys. + *state.keys.lock().map_err(|e| e.to_string())? = resolved.keys; + state.identity_lost.store( + resolved.recovery == RecoveryState::Lost, + std::sync::atomic::Ordering::Release, + ); + state.keyring_locked.store( + resolved.recovery == RecoveryState::KeyringLocked, + std::sync::atomic::Ordering::Release, + ); Ok(()) } /// Service name for the desktop OS keyring. Shared by the human identity key /// and managed-agent keys (each addressed by a distinct key name within it). -pub(crate) const KEYRING_SERVICE: &str = "buzz-desktop"; +/// +/// Debug builds use a distinct service name so dev and production keyring +/// entries never collide on the same machine. +pub(crate) fn keyring_service() -> &'static str { + if cfg!(debug_assertions) { + "buzz-desktop-dev" + } else { + "buzz-desktop" + } +} /// Keyring key name for the human identity nsec. const IDENTITY_KEY_NAME: &str = "identity"; @@ -196,6 +351,26 @@ const IDENTITY_KEY_NAME: &str = "identity"; /// keyring is merely unreachable (the key IS in the keyring, must NOT generate). const MIGRATION_MARKER_NAME: &str = "identity.migrated"; +/// Recovery state produced by identity resolution. `None` means the app has +/// a real, usable identity. `Lost` means the keyring was reachable-but-empty +/// despite a prior successful migration — the key vanished externally. `KeyringLocked` +/// means the keyring is unreachable this boot but was used in the past +/// (marker present, no file) — the key still exists but is temporarily +/// inaccessible. Both non-`None` variants boot with an ephemeral key; the +/// frontend shows a different recovery screen for each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecoveryState { + None, + Lost, + KeyringLocked, +} + +/// The output of identity resolution. +struct ResolvedIdentity { + keys: Keys, + recovery: RecoveryState, +} + /// The keyring operations the identity resolution flow needs. Abstracted so the /// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be /// unit-tested against a fake without touching the live OS keyring. @@ -204,6 +379,11 @@ trait IdentityKeyStore { fn load(&self, name: &str) -> Result, String>; fn store(&self, name: &str, value: &str) -> Result<(), String>; fn delete(&self, name: &str) -> Result<(), String>; + /// Verify that `key` holds `expected` by reading directly from the OS + /// backend — bypassing any in-process cache. Returns `Ok(true)` when the + /// stored value matches, `Ok(false)` when it does not or is absent, and + /// `Err` when the backend is unavailable. + fn verify_stored(&self, key: &str, expected: &str) -> Result; } impl IdentityKeyStore for crate::secret_store::SecretStore { @@ -219,6 +399,9 @@ impl IdentityKeyStore for crate::secret_store::SecretStore { fn delete(&self, name: &str) -> Result<(), String> { crate::secret_store::SecretStore::delete(self, name) } + fn verify_stored(&self, key: &str, expected: &str) -> Result { + crate::secret_store::SecretStore::verify_stored_raw(self, key, expected) + } } /// Resolve the human identity key: migrate a legacy `identity.key` into the @@ -230,15 +413,19 @@ impl IdentityKeyStore for crate::secret_store::SecretStore { /// this boot, fall back to reading the file directly and do NOT migrate — a /// later import from a leftover (possibly rotated) file could resurrect an old /// key. -fn load_or_create_identity(data_dir: &std::path::Path) -> Result { +fn load_or_create_identity(data_dir: &std::path::Path) -> Result { let legacy_path = data_dir.join("identity.key"); // No keyring available in this build: the `0o600` file is the only store. if !cfg!(feature = "system-keyring") { - return load_file_or_generate(&legacy_path, data_dir); + let keys = load_file_or_generate(&legacy_path, data_dir)?; + return Ok(ResolvedIdentity { + keys, + recovery: RecoveryState::None, + }); } - let store = crate::secret_store::SecretStore::shared(KEYRING_SERVICE); + let store = crate::secret_store::SecretStore::shared(keyring_service()); resolve_identity_with_store(store, &legacy_path, data_dir) } @@ -249,25 +436,95 @@ fn resolve_identity_with_store( store: &impl IdentityKeyStore, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result { +) -> Result { use crate::secret_store::KeyringProbe; match store.probe(IDENTITY_KEY_NAME) { KeyringProbe::Present => { if let Some(nsec) = store.load(IDENTITY_KEY_NAME)? { match Keys::parse(nsec.trim()) { - Ok(keys) => { + Ok(keyring_keys) => { eprintln!( "buzz-desktop: persisted identity pubkey {}", - keys.public_key().to_hex() + keyring_keys.public_key().to_hex() ); - // The key is authoritative in the keyring. A leftover - // `identity.key` means a prior migration's `remove_file` - // failed (transient AV lock, read-only mount, EPERM) and - // never retried — clean it up now so plaintext does not - // linger on disk. - cleanup_leftover_identity_file(legacy_path); - return Ok(keys); + // Check for a leftover identity.key. If it holds a + // DIFFERENT pubkey, the user imported that key after + // the last boot (pre-fix, import only wrote the file). + // Adopt it into the keyring so the user's intent sticks. + // If the pubkeys match it is a stale leftover from a + // prior migration whose remove_file failed — clean it up. + if legacy_path.exists() { + match load_key_file(legacy_path) { + Ok(file_keys) + if file_keys.public_key() != keyring_keys.public_key() => + { + eprintln!( + "buzz-desktop: identity.key differs from keyring; \ + adopting imported key {}", + file_keys.public_key().to_hex() + ); + // Delegate the store→read-back-verify→marker→delete + // sequence to `persist_identity_to_keyring`, which owns + // the marker-before-delete invariant and the fallback + // logic that keeps identity.key when the marker write + // fails. A transient keyring failure must not abort + // boot — the file key is safe and adoption retries next + // boot when the keyring is reachable again. + if let Err(e) = persist_identity_to_keyring( + store, + &file_keys, + legacy_path, + data_dir, + ) { + eprintln!( + "buzz-desktop: keyring adoption of identity.key \ + failed ({e}); using file key, will retry next boot" + ); + } + return Ok(ResolvedIdentity { + keys: file_keys, + recovery: RecoveryState::None, + }); + } + // Corrupt file — keyring is authoritative. Log before + // cleanup so there is a diagnostic for the lost data. + Err(e) => { + eprintln!( + "buzz-desktop: leftover identity.key is corrupt ({e}); \ + keyring is authoritative, removing" + ); + ensure_marker_then_cleanup(data_dir, legacy_path); + } + // Same pubkey (stale leftover from a completed migration + // whose remove_file previously failed) — keyring is + // authoritative. Ensure the marker exists (crash-safe + // ordering: marker before delete), then clean up. + Ok(_) => { + ensure_marker_then_cleanup(data_dir, legacy_path); + } + } + } + // Self-heal: if the identity.key is gone and the migration + // marker is absent (e.g. a stranded keyring-only install from + // a pre-fix path that stored to the keyring but could not write + // the marker or fallback file), write the marker now so a later + // keyring-Unreachable boot does not treat this as a fresh install + // and silently rotate the identity. Failure is non-fatal — boot + // must never be blocked here. + if !legacy_path.exists() && !migration_marker_path(data_dir).exists() { + if let Err(e) = write_migration_marker(&migration_marker_path(data_dir)) + { + eprintln!( + "buzz-desktop: keyring present but marker missing; \ + self-heal marker write failed ({e}), continuing" + ); + } + } + return Ok(ResolvedIdentity { + keys: keyring_keys, + recovery: RecoveryState::None, + }); } // The corruption is in the KEYRING, not the file. Clear the // bad keyring value and recover from the file (or generate @@ -282,16 +539,40 @@ fn resolve_identity_with_store( ); } } + } else { + // Probe said Present but load found nothing — treat as empty. + // Falls through to generate_and_persist below. } - // Probe said Present but load found nothing — treat as empty. } KeyringProbe::ReachableButEmpty => { // One-time migration: import the legacy plaintext file, read-back // verify, THEN delete it. if legacy_path.exists() { if let Some(keys) = migrate_identity_file(store, legacy_path, data_dir)? { - return Ok(keys); + return Ok(ResolvedIdentity { + keys, + recovery: RecoveryState::None, + }); } + } else if migration_marker_path(data_dir).exists() { + // Marker present, keyring empty, no file — the key was previously + // durably stored in the keyring but is now gone (keyring cleared, + // new login session, or the entry was externally deleted). There + // is no plaintext fallback to recover from. + // + // Generate an ephemeral in-memory key so the app can boot, but + // surface a "lost" flag so the frontend prompts re-import rather + // than silently starting a fresh identity. + let ephemeral = Keys::generate(); + eprintln!( + "buzz-desktop: identity lost — keyring was empty despite migration marker; \ + using ephemeral key {}, awaiting user re-import", + ephemeral.public_key().to_hex() + ); + return Ok(ResolvedIdentity { + keys: ephemeral, + recovery: RecoveryState::Lost, + }); } } KeyringProbe::Unreachable => { @@ -300,44 +581,89 @@ fn resolve_identity_with_store( // rotated key). With NO file, the marker disambiguates two states // that are otherwise byte-identical (Unreachable + no file): // - marker present → the key was migrated into the keyring and the - // file deleted. The real key is unreachable, not gone. Fail - // CLOSED — generating here would silently rotate the identity. + // file deleted. The real key is unreachable this boot but still + // exists in the keyring. Boot keyring-locked recovery (ephemeral + // key, all signing disabled) so the app can at least open; the + // frontend shows a "unlock the keyring and relaunch" screen. + // Fail-closed semantics are preserved: nothing is ever persisted + // under the ephemeral key, so no silent identity rotation occurs. // - no marker → genuine first-ever launch with nothing to protect. // Generate to the `0o600` file (legitimate first-run). if !legacy_path.exists() && migration_marker_path(data_dir).exists() { - return Err( - "identity key is in the OS keyring but the keyring is unavailable this boot; \ - retry once the keyring (Keychain / Credential Manager / Secret Service) is reachable" - .to_string(), + let ephemeral = Keys::generate(); + eprintln!( + "buzz-desktop: keyring unreachable but migration marker present; \ + booting keyring-locked recovery with ephemeral key {} — \ + unlock the keyring and relaunch", + ephemeral.public_key().to_hex() ); + return Ok(ResolvedIdentity { + keys: ephemeral, + recovery: RecoveryState::KeyringLocked, + }); } - return load_file_or_generate(legacy_path, data_dir); + let keys = load_file_or_generate(legacy_path, data_dir)?; + return Ok(ResolvedIdentity { + keys, + recovery: RecoveryState::None, + }); } } - generate_and_persist(store, legacy_path, data_dir) + let keys = generate_and_persist(store, legacy_path, data_dir)?; + Ok(ResolvedIdentity { + keys, + recovery: RecoveryState::None, + }) } /// Recover from a corrupt nsec in the keyring (parse failed). Clear the bad -/// keyring value, then migrate a valid leftover `identity.key` if one exists, -/// generating fresh only as a last resort. The keyring delete is best-effort: -/// a delete failure logs and continues — it must never block startup. +/// keyring value, then migrate a valid leftover `identity.key` if one exists. +/// If the migration marker is present but no valid file exists, the prior +/// identity is unrecoverable — return `Lost` recovery rather than silently +/// generating a new identity. Generating fresh is only correct when no prior +/// identity ever existed (no marker). The keyring delete is best-effort: a +/// delete failure logs and continues — it must never block startup. fn recover_from_keyring( store: &impl IdentityKeyStore, legacy_path: &std::path::Path, data_dir: &std::path::Path, error: &str, -) -> Result { +) -> Result { eprintln!("buzz-desktop: corrupt nsec in keyring ({error}), clearing and recovering from file"); if let Err(e) = store.delete(IDENTITY_KEY_NAME) { eprintln!("buzz-desktop: failed to clear corrupt keyring value: {e}"); } if legacy_path.exists() { if let Some(keys) = migrate_identity_file(store, legacy_path, data_dir)? { - return Ok(keys); + return Ok(ResolvedIdentity { + keys, + recovery: RecoveryState::None, + }); } } - generate_and_persist(store, legacy_path, data_dir) + // No valid file to recover from. If the migration marker exists, a prior + // identity was stored in the keyring and is now corrupt AND gone — the key + // is unrecoverable. Enter Lost recovery instead of silently rotating. + if migration_marker_path(data_dir).exists() { + let ephemeral = Keys::generate(); + eprintln!( + "buzz-desktop: identity lost — keyring had corrupt data and no valid identity.key \ + backup; prior identity (migration marker present) is unrecoverable; \ + using ephemeral key {}, awaiting user re-import", + ephemeral.public_key().to_hex() + ); + return Ok(ResolvedIdentity { + keys: ephemeral, + recovery: RecoveryState::Lost, + }); + } + // No marker: genuine first launch with a corrupt keyring. Generate fresh. + let keys = generate_and_persist(store, legacy_path, data_dir)?; + Ok(ResolvedIdentity { + keys, + recovery: RecoveryState::None, + }) } /// Load the `0o600` identity file, quarantining corruption, else generate and @@ -388,32 +714,143 @@ fn migrate_identity_file( .map_err(|e| format!("encode nsec: {e}"))?; store.store(IDENTITY_KEY_NAME, &nsec)?; - // Read-back verify before deleting the plaintext file. - match store.load(IDENTITY_KEY_NAME)? { - Some(stored) if stored == nsec => { - // Crash-safe ordering: record that the key now lives in the keyring - // (marker write + fsync) BEFORE deleting the file. A crash between - // the two must never leave "file gone, no marker" — that state is - // indistinguishable from a fresh install and would silently rotate - // the identity on the next keyring-unreachable boot. If the marker - // cannot be written, keep the file so the key is never stranded. - let marker_path = migration_marker_path(data_dir); - if let Err(e) = write_migration_marker(&marker_path) { - eprintln!( - "buzz-desktop: keyring import ok but failed to write migration marker ({e}); \ - keeping identity.key so the key is not stranded" - ); - return Ok(Some(keys)); - } - if let Err(e) = std::fs::remove_file(legacy_path) { - eprintln!("buzz-desktop: keyring import ok but failed to delete identity.key: {e}"); - } else { - eprintln!("buzz-desktop: migrated identity key into OS keyring"); - } - Ok(Some(keys)) - } - _ => Err("keyring read-back verify failed for identity key".to_string()), + // Read-back verify before deleting the plaintext file. Uses verify_stored() + // which bypasses the in-process cache and reads directly from the OS + // backend — proving the OS keyring round-trip, not just the cache. + let verify_ok = match store.verify_stored(IDENTITY_KEY_NAME, &nsec) { + Ok(b) => b, + Err(e) => return Err(format!("keyring read-back verify failed: {e}")), + }; + if !verify_ok { + return Err("keyring read-back verify failed for identity key".to_string()); } + // Crash-safe ordering: record that the key now lives in the keyring + // (marker write + fsync) BEFORE deleting the file. A crash between + // the two must never leave "file gone, no marker" — that state is + // indistinguishable from a fresh install and would silently rotate + // the identity on the next keyring-unreachable boot. If the marker + // cannot be written, keep the file so the key is never stranded. + let marker_path = migration_marker_path(data_dir); + if let Err(e) = write_migration_marker(&marker_path) { + eprintln!( + "buzz-desktop: keyring import ok but failed to write migration marker ({e}); \ + keeping identity.key so the key is not stranded" + ); + return Ok(Some(keys)); + } + if let Err(e) = std::fs::remove_file(legacy_path) { + eprintln!("buzz-desktop: keyring import ok but failed to delete identity.key: {e}"); + } else { + eprintln!("buzz-desktop: migrated identity key into OS keyring"); + } + Ok(Some(keys)) +} + +/// Persist `keys` into the keyring with read-back verification, write the +/// migration marker, and delete any leftover `identity.key`. Returns `Ok` on +/// success. Returns `Err` when the keyring write fails (availability error) — +/// the caller must fall back to `save_key_file` so the key survives the boot. +/// +/// This is the shared kernel used by both one-time file migration and the +/// `import_identity` command. Crash-safe ordering: marker is written BEFORE +/// deleting the file. +fn persist_identity_to_keyring( + store: &impl IdentityKeyStore, + keys: &Keys, + legacy_path: &std::path::Path, + data_dir: &std::path::Path, +) -> Result<(), String> { + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| format!("encode nsec: {e}"))?; + + // Will error if the keyring is unavailable — caller falls back to the file. + store.store(IDENTITY_KEY_NAME, &nsec)?; + + // Read-back verify before touching durable state. Uses verify_stored() + // which bypasses the in-process cache and reads directly from the OS + // backend — proving the OS keyring round-trip, not just the cache. + match store.verify_stored(IDENTITY_KEY_NAME, &nsec) { + Ok(true) => {} + Ok(false) => return Err("keyring read-back verify failed".to_string()), + Err(e) => return Err(format!("keyring read-back verify failed: {e}")), + } + + // Write marker before deleting the file (crash-safe ordering). + let marker_path = migration_marker_path(data_dir); + if let Err(e) = write_migration_marker(&marker_path) { + // Keyring holds the key but no marker exists. Preserve the invariant + // "keyring-only implies marker exists" by ensuring identity.key is + // present as a fallback: write it if absent, leave it if already there. + // This prevents a later keyring-unreachable + no-marker boot from + // treating this as a fresh install and silently rotating identity. + if !legacy_path.exists() { + if let Err(write_err) = save_key_file(legacy_path, keys) { + eprintln!( + "buzz-desktop: keyring ok but marker write failed ({e}) and \ + identity.key write also failed ({write_err}); key may be unrecoverable" + ); + return Err(format!( + "keyring ok but neither migration marker nor identity.key fallback \ + could be written (marker: {e}; file: {write_err}); \ + identity must not be treated as durably persisted — retry the import" + )); + } else { + eprintln!( + "buzz-desktop: keyring ok but marker write failed ({e}); \ + wrote identity.key as fallback so the key is not stranded" + ); + } + } else { + eprintln!( + "buzz-desktop: keyring ok but marker write failed ({e}); \ + keeping existing identity.key so the key is not stranded" + ); + } + return Ok(()); + } + + if legacy_path.exists() { + if let Err(e) = std::fs::remove_file(legacy_path) { + eprintln!("buzz-desktop: keyring write ok but failed to delete identity.key: {e}"); + } + } + + Ok(()) +} + +/// Core implementation of imported-identity persistence. Tries the OS keyring +/// first via [`persist_identity_to_keyring`]; if the keyring is unavailable, +/// falls back to the `0o600` identity.key file. Returns `Err` only when both +/// the keyring write and the file fallback fail. +fn persist_imported_identity_impl( + store: &impl IdentityKeyStore, + keys: &Keys, + legacy_path: &std::path::Path, + data_dir: &std::path::Path, +) -> Result<(), String> { + match persist_identity_to_keyring(store, keys, legacy_path, data_dir) { + Ok(()) => Ok(()), + Err(e) => { + eprintln!( + "buzz-desktop: keyring write failed during import ({e}), \ + falling back to identity.key" + ); + save_key_file(legacy_path, keys) + } + } +} + +/// Public entry point binding [`persist_imported_identity_impl`] to the shared +/// [`crate::secret_store::SecretStore`]. See the impl for the persistence policy. +pub(crate) fn persist_imported_identity( + store: &crate::secret_store::SecretStore, + keys: &Keys, + legacy_path: &std::path::Path, + data_dir: &std::path::Path, +) -> Result<(), String> { + persist_imported_identity_impl(store, keys, legacy_path, data_dir) } /// Path of the migration-completed marker within `data_dir`. @@ -437,10 +874,10 @@ fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("commit migration marker: {e}")) } -/// Which backend `persist_identity` wrote to. The caller writes the migration -/// marker only after a keyring success — on the file-fallback arm the key is on -/// disk and a marker would wrongly trip the next Unreachable boot into failing -/// closed. +/// Which backend [`store_key_preferring_keyring`] wrote to. The caller writes +/// the migration marker only after a keyring success — on the file-fallback arm +/// the key is on disk and a marker would wrongly trip the next Unreachable boot +/// into failing closed. enum PersistBackend { Keyring, File, @@ -459,7 +896,7 @@ fn generate_and_persist( data_dir: &std::path::Path, ) -> Result { let keys = Keys::generate(); - if let PersistBackend::Keyring = persist_identity(store, &keys, legacy_path)? { + if let PersistBackend::Keyring = store_key_preferring_keyring(store, &keys, legacy_path)? { let marker_path = migration_marker_path(data_dir); if let Err(e) = write_migration_marker(&marker_path) { eprintln!( @@ -476,10 +913,12 @@ fn generate_and_persist( Ok(keys) } -/// Persist `keys` through the store, falling back to the `0o600` file when the -/// keyring write fails on an availability error. Reports which backend held the -/// key so the caller can write the migration marker only on keyring success. -fn persist_identity( +/// Persist `keys` through the store, silently falling back to the `0o600` file +/// when the keyring write fails on an availability error. Reports which backend +/// held the key (no verify/marker/delete — those belong to callers that own the +/// full migration contract) so the caller can write the migration marker only on +/// keyring success. +fn store_key_preferring_keyring( store: &impl IdentityKeyStore, keys: &Keys, legacy_path: &std::path::Path, @@ -498,6 +937,28 @@ fn persist_identity( } } +/// Ensure the migration marker exists (writing it if absent), then remove the +/// leftover `identity.key`. Crash-safe ordering: the marker is written and +/// fsync-committed before the file is deleted, so a crash between the two +/// leaves the marker on disk and the file intact — the invariant "keyring-only +/// implies marker exists" is preserved. If the marker write fails, the file is +/// kept so a later keyring-unreachable boot can use it as a fallback. +fn ensure_marker_then_cleanup(data_dir: &std::path::Path, legacy_path: &std::path::Path) { + let marker_path = migration_marker_path(data_dir); + let marker_ok = marker_path.exists() + || write_migration_marker(&marker_path) + .map_err(|e| { + eprintln!( + "buzz-desktop: keyring present but marker missing; \ + failed to write marker ({e}), keeping identity.key" + ); + }) + .is_ok(); + if marker_ok { + cleanup_leftover_identity_file(legacy_path); + } +} + /// Best-effort removal of a leftover `identity.key` once the keyring is the /// authoritative store. Idempotent: a missing file is success. Logs but does /// not error on failure — a delete failure must never block startup. @@ -573,460 +1034,5 @@ pub(crate) fn save_key_file(path: &std::path::Path, keys: &Keys) -> Result<(), S } #[cfg(test)] -mod tests { - use super::*; - - fn assert_key_eq(a: &Keys, b: &Keys) { - assert_eq!(a.public_key().to_hex(), b.public_key().to_hex()); - } - - /// `BUZZ_PRIVATE_KEY` is process-global; serialize the env-mutating tests - /// so they don't race each other under the parallel test runner. - static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - /// Run `body` with `BUZZ_PRIVATE_KEY` set to `value` (or unset when `None`), - /// restoring the prior value afterward. - fn with_env_key(value: Option<&str>, body: impl FnOnce() -> T) -> T { - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let prior = std::env::var("BUZZ_PRIVATE_KEY").ok(); - match value { - Some(v) => std::env::set_var("BUZZ_PRIVATE_KEY", v), - None => std::env::remove_var("BUZZ_PRIVATE_KEY"), - } - let out = body(); - match prior { - Some(v) => std::env::set_var("BUZZ_PRIVATE_KEY", v), - None => std::env::remove_var("BUZZ_PRIVATE_KEY"), - } - out - } - - #[test] - fn identity_from_env_wins_when_valid() { - let configured = Keys::generate(); - let nsec = configured.secret_key().to_bech32().unwrap(); - - let resolved = - with_env_key(Some(&nsec), identity_from_env).expect("valid env key must resolve"); - - assert_key_eq(&configured, &resolved); - } - - #[test] - fn identity_from_env_none_when_absent() { - assert!(with_env_key(None, identity_from_env).is_none()); - } - - #[test] - fn identity_from_env_none_when_malformed() { - // A malformed env var falls through to persisted resolution rather than - // winning — otherwise a typo'd key would silently shadow the real one. - assert!(with_env_key(Some("not-a-valid-nsec"), identity_from_env).is_none()); - } - - #[test] - fn save_and_load_round_trip() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.key"); - let keys = Keys::generate(); - - save_key_file(&path, &keys).unwrap(); - let loaded = load_key_file(&path).unwrap(); - assert_key_eq(&keys, &loaded); - } - - #[test] - fn load_rejects_empty_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.key"); - std::fs::write(&path, "").unwrap(); - - assert!(load_key_file(&path).is_err()); - } - - #[test] - fn load_rejects_corrupt_content() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.key"); - std::fs::write(&path, "not-a-valid-nsec").unwrap(); - - assert!(load_key_file(&path).is_err()); - } - - #[test] - fn load_missing_file_is_err() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("nonexistent.key"); - - assert!(load_key_file(&path).is_err()); - } - - #[test] - fn cleanup_removes_leftover_identity_file() { - // Item 1: a leftover identity.key (from a migration whose remove_file - // failed) is deleted once the keyring is authoritative, so plaintext - // does not linger on disk. - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.key"); - save_key_file(&path, &Keys::generate()).unwrap(); - assert!(path.exists()); - - cleanup_leftover_identity_file(&path); - - assert!(!path.exists()); - } - - #[test] - fn cleanup_is_noop_when_no_leftover_file() { - // Idempotent: the cleanup runs on every keyring-Present boot, so a - // missing file must be a silent success, not an error or panic. - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.key"); - assert!(!path.exists()); - - cleanup_leftover_identity_file(&path); - - assert!(!path.exists()); - } - - #[test] - fn save_creates_file_with_valid_nsec() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.key"); - let keys = Keys::generate(); - - save_key_file(&path, &keys).unwrap(); - - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.starts_with("nsec1")); - } - - #[cfg(unix)] - #[test] - fn save_creates_file_with_restricted_permissions() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.key"); - let keys = Keys::generate(); - - save_key_file(&path, &keys).unwrap(); - - let perms = std::fs::metadata(&path).unwrap().permissions(); - assert_eq!(perms.mode() & 0o777, 0o600); - } - - #[test] - fn save_overwrites_existing_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.key"); - - let keys1 = Keys::generate(); - save_key_file(&path, &keys1).unwrap(); - - let keys2 = Keys::generate(); - save_key_file(&path, &keys2).unwrap(); - - let loaded = load_key_file(&path).unwrap(); - assert_key_eq(&keys2, &loaded); - } - - use std::cell::RefCell; - use std::collections::HashMap; - - use crate::secret_store::KeyringProbe; - - /// In-memory [`IdentityKeyStore`] for testing identity recovery without the - /// OS keyring. Seeded with an initial value and a probe outcome; records - /// every `delete`/`store` so tests can assert the keyring was cleared and - /// rewritten. `write_and_verify` succeeds (store then load reflects it). - struct FakeIdentityStore { - probe: KeyringProbe, - slot: RefCell>, - deleted: RefCell>, - /// When true, `store` returns an availability error, driving the - /// keyring-write-failure → file-fallback arm of `persist_identity`. - store_fails: bool, - } - - impl FakeIdentityStore { - fn present_with(value: &str) -> Self { - let mut slot = HashMap::new(); - slot.insert(IDENTITY_KEY_NAME.to_string(), value.to_string()); - Self { - probe: KeyringProbe::Present, - slot: RefCell::new(slot), - deleted: RefCell::new(Vec::new()), - store_fails: false, - } - } - - /// Backend down this boot: probe is `Unreachable` and the slot is empty - /// (the real key, if any, is in the keyring we cannot reach). - fn unreachable() -> Self { - Self { - probe: KeyringProbe::Unreachable, - slot: RefCell::new(HashMap::new()), - deleted: RefCell::new(Vec::new()), - store_fails: false, - } - } - - /// Backend reachable with no entry — drives the one-time migration path. - /// `store`/`load` go through the slot, so a read-back verify succeeds. - fn reachable_but_empty() -> Self { - Self { - probe: KeyringProbe::ReachableButEmpty, - slot: RefCell::new(HashMap::new()), - deleted: RefCell::new(Vec::new()), - store_fails: false, - } - } - - /// Reachable-but-empty probe whose `store` always fails — exercises the - /// keyring-write-failure → `0o600` file-fallback arm. - fn store_failing() -> Self { - Self { - probe: KeyringProbe::ReachableButEmpty, - slot: RefCell::new(HashMap::new()), - deleted: RefCell::new(Vec::new()), - store_fails: true, - } - } - } - - impl IdentityKeyStore for FakeIdentityStore { - fn probe(&self, _name: &str) -> KeyringProbe { - self.probe - } - fn load(&self, name: &str) -> Result, String> { - Ok(self.slot.borrow().get(name).cloned()) - } - fn store(&self, name: &str, value: &str) -> Result<(), String> { - if self.store_fails { - return Err("simulated keyring write failure".to_string()); - } - self.slot - .borrow_mut() - .insert(name.to_string(), value.to_string()); - Ok(()) - } - fn delete(&self, name: &str) -> Result<(), String> { - self.deleted.borrow_mut().push(name.to_string()); - self.slot.borrow_mut().remove(name); - Ok(()) - } - } - - #[test] - fn corrupt_keyring_recovers_valid_file_without_rotating() { - // The load-bearing regression guard. When the keyring holds a corrupt - // nsec (Present) AND a valid `identity.key` is on disk (leftover from a - // failed prior migration), recovery must RECOVER THE FILE'S identity — - // not quarantine the file and rotate to a fresh key (the original - // hazard). The corrupt keyring value must be cleared and replaced by the - // file's key (migrated in). - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("identity.key"); - let file_keys = Keys::generate(); - save_key_file(&legacy_path, &file_keys).unwrap(); - - let store = FakeIdentityStore::present_with("not-a-valid-nsec"); - let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - - // The FILE's identity is recovered — NOT a freshly generated one. - assert_key_eq(&file_keys, &resolved); - // The corrupt keyring value was cleared. - assert_eq!(store.deleted.borrow().as_slice(), [IDENTITY_KEY_NAME]); - // The keyring now holds the file's key (migrated in, read-back verified). - let file_nsec = file_keys.secret_key().to_bech32().unwrap(); - assert_eq!( - store - .slot - .borrow() - .get(IDENTITY_KEY_NAME) - .map(String::as_str), - Some(file_nsec.as_str()) - ); - // The valid file was migrated (deleted), not quarantined to .bad.*. - assert!(!legacy_path.exists()); - assert!(std::fs::read_dir(dir.path()).unwrap().all(|e| !e - .unwrap() - .file_name() - .to_string_lossy() - .contains(".bad."))); - } - - #[test] - fn corrupt_keyring_generates_fresh_only_when_no_file() { - // With a corrupt keyring value and NO file on disk, generate-fresh is - // the correct last resort — and the corrupt keyring value is cleared - // first. - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("identity.key"); - assert!(!legacy_path.exists()); - - let store = FakeIdentityStore::present_with("not-a-valid-nsec"); - let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - - assert_eq!(store.deleted.borrow().as_slice(), [IDENTITY_KEY_NAME]); - // A fresh, valid key was persisted to the keyring (replacing the cleared - // corrupt value). - let stored = store.slot.borrow().get(IDENTITY_KEY_NAME).cloned(); - assert_eq!( - stored.as_deref(), - Some(resolved.secret_key().to_bech32().unwrap().as_str()) - ); - } - - #[test] - fn valid_keyring_is_used_and_leftover_file_cleaned_up() { - // The happy path is unchanged: a valid keyring value is used as-is, and - // a leftover plaintext file is cleaned up (keyring is authoritative). - let keyring_keys = Keys::generate(); - let nsec = keyring_keys.secret_key().to_bech32().unwrap(); - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("identity.key"); - save_key_file(&legacy_path, &Keys::generate()).unwrap(); - - let store = FakeIdentityStore::present_with(&nsec); - let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - - assert_key_eq(&keyring_keys, &resolved); - assert!(store.deleted.borrow().is_empty()); - assert!(!legacy_path.exists()); - } - - #[test] - fn unreachable_post_migration_fails_closed_when_marker_present() { - // The silent-rotation hazard (Wes Comment 1). After a migration the - // file is gone and the marker exists; a later boot with the keyring - // unreachable must FAIL CLOSED — the real key is in the keyring, not - // gone, so generating a fresh one would silently rotate the identity. - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("identity.key"); - write_migration_marker(&migration_marker_path(dir.path())).unwrap(); - assert!(!legacy_path.exists()); - - let store = FakeIdentityStore::unreachable(); - let result = resolve_identity_with_store(&store, &legacy_path, dir.path()); - - assert!( - result.is_err(), - "must fail closed, not generate a fresh key" - ); - // No identity file was written — nothing was generated or persisted. - assert!(!legacy_path.exists()); - } - - #[test] - fn unreachable_first_run_generates_to_file_when_no_marker() { - // Genuine first-EVER launch on a machine whose keyring is down: no file, - // no marker. There is no prior identity to protect, so generating to the - // `0o600` file is correct — fail-closed here would block a legitimate - // first launch. - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("identity.key"); - assert!(!legacy_path.exists()); - assert!(!migration_marker_path(dir.path()).exists()); - - let store = FakeIdentityStore::unreachable(); - let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - - // A fresh key was generated and persisted to the file (keyring is down). - let from_file = load_key_file(&legacy_path).unwrap(); - assert_key_eq(&resolved, &from_file); - } - - #[test] - fn migration_writes_marker_before_deleting_file() { - // Crash-safe ordering: a successful migration must leave the marker on - // disk AND remove the file. The marker existing while the file is gone - // is the durable post-migration signal the Unreachable arm relies on; - // "file gone, no marker" must never be the resting state. - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("identity.key"); - let file_keys = Keys::generate(); - save_key_file(&legacy_path, &file_keys).unwrap(); - - // ReachableButEmpty drives the one-time migration path. - let store = FakeIdentityStore::reachable_but_empty(); - let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - - assert_key_eq(&file_keys, &resolved); - // Marker written, file deleted — the safe resting state. - assert!(migration_marker_path(dir.path()).exists()); - assert!(!legacy_path.exists()); - } - - #[test] - fn fresh_keyring_generate_writes_marker() { - // Fix 1 (Pinky comment 1): a fresh install generating straight into a - // reachable-but-empty keyring must write the marker. Without it, "no - // file, no marker" matches a never-launched machine, so a later - // Unreachable boot would silently rotate the key. - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("identity.key"); - assert!(!legacy_path.exists()); - - let store = FakeIdentityStore::reachable_but_empty(); - let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - - // The key was stored in the keyring (not the file), and the marker marks it. - assert!(!legacy_path.exists()); - assert!(migration_marker_path(dir.path()).exists()); - assert_eq!( - store - .slot - .borrow() - .get(IDENTITY_KEY_NAME) - .map(String::as_str), - Some(resolved.secret_key().to_bech32().unwrap().as_str()) - ); - } - - #[test] - fn fresh_keyring_generate_then_unreachable_fails_closed() { - // The end-to-end guard for Fix 1: after a fresh keyring-created identity - // (marker written, no file), a later boot with the keyring unreachable - // must FAIL CLOSED rather than generate a new key and rotate identity. - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("identity.key"); - - // First boot: fresh generate into a reachable keyring. - let reachable = FakeIdentityStore::reachable_but_empty(); - resolve_identity_with_store(&reachable, &legacy_path, dir.path()).unwrap(); - assert!(!legacy_path.exists()); - assert!(migration_marker_path(dir.path()).exists()); - - // Second boot: keyring is down. No file + marker present → fail closed. - let unreachable = FakeIdentityStore::unreachable(); - let result = resolve_identity_with_store(&unreachable, &legacy_path, dir.path()); - - assert!( - result.is_err(), - "must fail closed, not generate a fresh key" - ); - assert!(!legacy_path.exists()); - } - - #[test] - fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() { - // Fix 1 correctness on the file-fallback arm: when the keyring write - // FAILS during a fresh generate, the key must land in the `0o600` file - // and the marker must NOT be written — a marker here would wrongly trip - // the next Unreachable boot into failing closed even though the key is - // sitting in the file. - let dir = tempfile::tempdir().unwrap(); - let legacy_path = dir.path().join("identity.key"); - - let store = FakeIdentityStore::store_failing(); - let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - - // Key persisted to the file (fallback), and recoverable from it. - let from_file = load_key_file(&legacy_path).unwrap(); - assert_key_eq(&resolved, &from_file); - // No marker: the file is the authoritative store, not the keyring. - assert!(!migration_marker_path(dir.path()).exists()); - } -} +#[path = "app_state_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs new file mode 100644 index 0000000000..485dfaea15 --- /dev/null +++ b/desktop/src-tauri/src/app_state_tests.rs @@ -0,0 +1,1417 @@ +use super::*; + +fn assert_key_eq(a: &Keys, b: &Keys) { + assert_eq!(a.public_key().to_hex(), b.public_key().to_hex()); +} + +/// `BUZZ_PRIVATE_KEY` is process-global; serialize the env-mutating tests +/// so they don't race each other under the parallel test runner. +static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Run `body` with `BUZZ_PRIVATE_KEY` set to `value` (or unset when `None`), +/// restoring the prior value afterward. +fn with_env_key(value: Option<&str>, body: impl FnOnce() -> T) -> T { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prior = std::env::var("BUZZ_PRIVATE_KEY").ok(); + match value { + Some(v) => std::env::set_var("BUZZ_PRIVATE_KEY", v), + None => std::env::remove_var("BUZZ_PRIVATE_KEY"), + } + let out = body(); + match prior { + Some(v) => std::env::set_var("BUZZ_PRIVATE_KEY", v), + None => std::env::remove_var("BUZZ_PRIVATE_KEY"), + } + out +} + +#[test] +fn identity_from_env_wins_when_valid() { + let configured = Keys::generate(); + let nsec = configured.secret_key().to_bech32().unwrap(); + + let resolved = + with_env_key(Some(&nsec), identity_from_env).expect("valid env key must resolve"); + + assert_key_eq(&configured, &resolved); +} + +#[test] +fn identity_from_env_none_when_absent() { + assert!(with_env_key(None, identity_from_env).is_none()); +} + +#[test] +fn identity_from_env_none_when_malformed() { + // A malformed env var falls through to persisted resolution rather than + // winning — otherwise a typo'd key would silently shadow the real one. + assert!(with_env_key(Some("not-a-valid-nsec"), identity_from_env).is_none()); +} + +#[test] +fn save_and_load_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("identity.key"); + let keys = Keys::generate(); + + save_key_file(&path, &keys).unwrap(); + let loaded = load_key_file(&path).unwrap(); + assert_key_eq(&keys, &loaded); +} + +#[test] +fn load_rejects_empty_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("identity.key"); + std::fs::write(&path, "").unwrap(); + + assert!(load_key_file(&path).is_err()); +} + +#[test] +fn load_rejects_corrupt_content() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("identity.key"); + std::fs::write(&path, "not-a-valid-nsec").unwrap(); + + assert!(load_key_file(&path).is_err()); +} + +#[test] +fn load_missing_file_is_err() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nonexistent.key"); + + assert!(load_key_file(&path).is_err()); +} + +#[test] +fn cleanup_removes_leftover_identity_file() { + // Item 1: a leftover identity.key (from a migration whose remove_file + // failed) is deleted once the keyring is authoritative, so plaintext + // does not linger on disk. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("identity.key"); + save_key_file(&path, &Keys::generate()).unwrap(); + assert!(path.exists()); + + cleanup_leftover_identity_file(&path); + + assert!(!path.exists()); +} + +#[test] +fn cleanup_is_noop_when_no_leftover_file() { + // Idempotent: the cleanup runs on every keyring-Present boot, so a + // missing file must be a silent success, not an error or panic. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("identity.key"); + assert!(!path.exists()); + + cleanup_leftover_identity_file(&path); + + assert!(!path.exists()); +} + +#[test] +fn save_creates_file_with_valid_nsec() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("identity.key"); + let keys = Keys::generate(); + + save_key_file(&path, &keys).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.starts_with("nsec1")); +} + +#[cfg(unix)] +#[test] +fn save_creates_file_with_restricted_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("identity.key"); + let keys = Keys::generate(); + + save_key_file(&path, &keys).unwrap(); + + let perms = std::fs::metadata(&path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o777, 0o600); +} + +#[test] +fn save_overwrites_existing_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("identity.key"); + + let keys1 = Keys::generate(); + save_key_file(&path, &keys1).unwrap(); + + let keys2 = Keys::generate(); + save_key_file(&path, &keys2).unwrap(); + + let loaded = load_key_file(&path).unwrap(); + assert_key_eq(&keys2, &loaded); +} + +use std::cell::RefCell; +use std::collections::HashMap; + +use crate::secret_store::KeyringProbe; + +/// In-memory [`IdentityKeyStore`] for testing identity recovery without the +/// OS keyring. Seeded with an initial value and a probe outcome; records +/// every `delete`/`store` so tests can assert the keyring was cleared and +/// rewritten. `write_and_verify` succeeds (store then load reflects it). +struct FakeIdentityStore { + probe: KeyringProbe, + slot: RefCell>, + deleted: RefCell>, + /// When true, `store` returns an availability error, driving the + /// keyring-write-failure → file-fallback arm of `store_key_preferring_keyring`. + store_fails: bool, + /// When `Some`, `load()` always returns this value regardless of what was + /// stored. Used to simulate read-back corruption: `store()` succeeds but + /// the subsequent `load()` returns a different value, causing + /// `persist_identity_to_keyring`'s read-back verify to fail. + load_override: Option, + /// When true, `verify_stored()` always returns `Ok(false)` — simulates + /// a backend that stores successfully but cannot be read back (e.g. an OS + /// keyring that advances its in-process cache but fails to durably persist). + verify_fails: bool, +} + +impl FakeIdentityStore { + fn present_with(value: &str) -> Self { + let mut slot = HashMap::new(); + slot.insert(IDENTITY_KEY_NAME.to_string(), value.to_string()); + Self { + probe: KeyringProbe::Present, + slot: RefCell::new(slot), + deleted: RefCell::new(Vec::new()), + store_fails: false, + load_override: None, + verify_fails: false, + } + } + + /// Backend down this boot: probe is `Unreachable` and the slot is empty + /// (the real key, if any, is in the keyring we cannot reach). + fn unreachable() -> Self { + Self { + probe: KeyringProbe::Unreachable, + slot: RefCell::new(HashMap::new()), + deleted: RefCell::new(Vec::new()), + store_fails: false, + load_override: None, + verify_fails: false, + } + } + + /// Backend reachable with no entry — drives the one-time migration path. + /// `store`/`load` go through the slot, so a read-back verify succeeds. + fn reachable_but_empty() -> Self { + Self { + probe: KeyringProbe::ReachableButEmpty, + slot: RefCell::new(HashMap::new()), + deleted: RefCell::new(Vec::new()), + store_fails: false, + load_override: None, + verify_fails: false, + } + } + + /// Present probe seeded with a value but whose `store` always fails — + /// exercises the keyring-write-failure arm of adoption and import paths. + fn present_with_store_failing(value: &str) -> Self { + let mut slot = HashMap::new(); + slot.insert(IDENTITY_KEY_NAME.to_string(), value.to_string()); + Self { + probe: KeyringProbe::Present, + slot: RefCell::new(slot), + deleted: RefCell::new(Vec::new()), + store_fails: true, + load_override: None, + verify_fails: false, + } + } + + /// Reachable-but-empty probe whose `store` always fails — exercises the + /// keyring-write-failure → `0o600` file-fallback arm. + fn store_failing() -> Self { + Self { + probe: KeyringProbe::ReachableButEmpty, + slot: RefCell::new(HashMap::new()), + deleted: RefCell::new(Vec::new()), + store_fails: true, + load_override: None, + verify_fails: false, + } + } + + /// Reachable-but-empty probe whose `store` succeeds but whose `load` + /// always returns `corrupt_nsec` — simulates keyring read-back corruption. + /// `persist_identity_to_keyring`'s read-back verify sees a mismatch and + /// returns `Err("keyring read-back verify failed")`. + fn with_readback_corruption(corrupt_nsec: &str) -> Self { + Self { + probe: KeyringProbe::ReachableButEmpty, + slot: RefCell::new(HashMap::new()), + deleted: RefCell::new(Vec::new()), + store_fails: false, + load_override: Some(corrupt_nsec.to_string()), + verify_fails: false, + } + } + + /// Reachable-but-empty probe whose `store` succeeds but whose + /// `verify_stored` always returns `Ok(false)` — simulates a backend that + /// writes to a cache but cannot confirm the OS-level round-trip. + /// `persist_identity_to_keyring` will treat this as a read-back failure. + fn with_verify_failing() -> Self { + Self { + probe: KeyringProbe::ReachableButEmpty, + slot: RefCell::new(HashMap::new()), + deleted: RefCell::new(Vec::new()), + store_fails: false, + load_override: None, + verify_fails: true, + } + } +} + +impl IdentityKeyStore for FakeIdentityStore { + fn probe(&self, _name: &str) -> KeyringProbe { + self.probe + } + fn load(&self, name: &str) -> Result, String> { + if let Some(v) = &self.load_override { + return Ok(Some(v.clone())); + } + Ok(self.slot.borrow().get(name).cloned()) + } + fn store(&self, name: &str, value: &str) -> Result<(), String> { + if self.store_fails { + return Err("simulated keyring write failure".to_string()); + } + self.slot + .borrow_mut() + .insert(name.to_string(), value.to_string()); + Ok(()) + } + fn delete(&self, name: &str) -> Result<(), String> { + self.deleted.borrow_mut().push(name.to_string()); + self.slot.borrow_mut().remove(name); + Ok(()) + } + fn verify_stored(&self, name: &str, expected: &str) -> Result { + if self.verify_fails { + return Ok(false); + } + // When load_override is set, verify_stored must also reflect the + // override — the override simulates a backend that returns a different + // value regardless of what was stored, so both load() and verify_stored() + // should see it. This mirrors the real `with_readback_corruption` scenario. + if let Some(v) = &self.load_override { + return Ok(v == expected); + } + Ok(self.slot.borrow().get(name).is_some_and(|v| v == expected)) + } +} + +#[test] +fn corrupt_keyring_recovers_valid_file_without_rotating() { + // The load-bearing regression guard. When the keyring holds a corrupt + // nsec (Present) AND a valid `identity.key` is on disk (leftover from a + // failed prior migration), recovery must RECOVER THE FILE'S identity — + // not quarantine the file and rotate to a fresh key (the original + // hazard). The corrupt keyring value must be cleared and replaced by the + // file's key (migrated in). + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + let file_keys = Keys::generate(); + save_key_file(&legacy_path, &file_keys).unwrap(); + + let store = FakeIdentityStore::present_with("not-a-valid-nsec"); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // The FILE's identity is recovered — NOT a freshly generated one. + assert_key_eq(&file_keys, &resolved.keys); + // The corrupt keyring value was cleared. + assert_eq!(store.deleted.borrow().as_slice(), [IDENTITY_KEY_NAME]); + // The keyring now holds the file's key (migrated in, read-back verified). + let file_nsec = file_keys.secret_key().to_bech32().unwrap(); + assert_eq!( + store + .slot + .borrow() + .get(IDENTITY_KEY_NAME) + .map(String::as_str), + Some(file_nsec.as_str()) + ); + // The valid file was migrated (deleted), not quarantined to .bad.*. + assert!(!legacy_path.exists()); + assert!(std::fs::read_dir(dir.path()).unwrap().all(|e| !e + .unwrap() + .file_name() + .to_string_lossy() + .contains(".bad."))); +} + +#[test] +fn corrupt_keyring_generates_fresh_only_when_no_file() { + // With a corrupt keyring value and NO file on disk, generate-fresh is + // the correct last resort — and the corrupt keyring value is cleared + // first. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + assert!(!legacy_path.exists()); + + let store = FakeIdentityStore::present_with("not-a-valid-nsec"); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_eq!(store.deleted.borrow().as_slice(), [IDENTITY_KEY_NAME]); + // A fresh, valid key was persisted to the keyring (replacing the cleared + // corrupt value). + let stored = store.slot.borrow().get(IDENTITY_KEY_NAME).cloned(); + assert_eq!( + stored.as_deref(), + Some(resolved.keys.secret_key().to_bech32().unwrap().as_str()) + ); +} + +#[test] +fn valid_keyring_is_used_and_matching_leftover_file_cleaned_up() { + // A valid keyring entry and a leftover identity.key with the SAME pubkey + // (stale leftover from a migration whose remove_file previously failed): + // keyring wins, plaintext is removed without adoption. + let keyring_keys = Keys::generate(); + let nsec = keyring_keys.secret_key().to_bech32().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + // Same key in file as keyring → stale leftover, not an import. + save_key_file(&legacy_path, &keyring_keys).unwrap(); + + let store = FakeIdentityStore::present_with(&nsec); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_key_eq(&keyring_keys, &resolved.keys); + assert_eq!(resolved.recovery, RecoveryState::None); + assert!(store.deleted.borrow().is_empty()); + assert!(!legacy_path.exists()); +} + +#[test] +fn unreachable_post_migration_boots_keyring_locked_recovery() { + // After a migration the file is gone and the marker exists. A later boot + // with the keyring unreachable must NOT generate a fresh key (that would + // silently rotate the identity), but must also allow the app to open + // instead of hard-aborting. The result is a keyring-locked recovery boot: + // ephemeral key held in memory only, nothing persisted anywhere. + // + // Fail-closed semantics are preserved: no identity is ever written to disk + // or the keyring under the ephemeral key, so no silent rotation occurs. + // The abort is replaced by a graceful recovery screen. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + write_migration_marker(&migration_marker_path(dir.path())).unwrap(); + assert!(!legacy_path.exists()); + + let store = FakeIdentityStore::unreachable(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // KeyringLocked recovery: ephemeral key returned, nothing persisted. + assert_eq!(resolved.recovery, RecoveryState::KeyringLocked); + // No identity.key was written. + assert!(!legacy_path.exists()); + // Keyring store was never called (it is unreachable). + assert!(store.slot.borrow().is_empty()); + assert!(store.deleted.borrow().is_empty()); +} + +#[test] +fn unreachable_first_run_generates_to_file_when_no_marker() { + // Genuine first-EVER launch on a machine whose keyring is down: no file, + // no marker. There is no prior identity to protect, so generating to the + // `0o600` file is correct — fail-closed here would block a legitimate + // first launch. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + assert!(!legacy_path.exists()); + assert!(!migration_marker_path(dir.path()).exists()); + + let store = FakeIdentityStore::unreachable(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // A fresh key was generated and persisted to the file (keyring is down). + let from_file = load_key_file(&legacy_path).unwrap(); + assert_key_eq(&resolved.keys, &from_file); +} + +#[test] +fn migration_writes_marker_before_deleting_file() { + // Crash-safe ordering: a successful migration must leave the marker on + // disk AND remove the file. The marker existing while the file is gone + // is the durable post-migration signal the Unreachable arm relies on; + // "file gone, no marker" must never be the resting state. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + let file_keys = Keys::generate(); + save_key_file(&legacy_path, &file_keys).unwrap(); + + // ReachableButEmpty drives the one-time migration path. + let store = FakeIdentityStore::reachable_but_empty(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_key_eq(&file_keys, &resolved.keys); + // Marker written, file deleted — the safe resting state. + assert!(migration_marker_path(dir.path()).exists()); + assert!(!legacy_path.exists()); +} + +#[test] +fn fresh_keyring_generate_writes_marker() { + // Fix 1 (Pinky comment 1): a fresh install generating straight into a + // reachable-but-empty keyring must write the marker. Without it, "no + // file, no marker" matches a never-launched machine, so a later + // Unreachable boot would silently rotate the key. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + assert!(!legacy_path.exists()); + + let store = FakeIdentityStore::reachable_but_empty(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // The key was stored in the keyring (not the file), and the marker marks it. + assert!(!legacy_path.exists()); + assert!(migration_marker_path(dir.path()).exists()); + assert_eq!( + store + .slot + .borrow() + .get(IDENTITY_KEY_NAME) + .map(String::as_str), + Some(resolved.keys.secret_key().to_bech32().unwrap().as_str()) + ); +} + +#[test] +fn fresh_keyring_generate_then_unreachable_boots_locked_recovery() { + // End-to-end guard for Fix 1: after a fresh keyring-created identity + // (marker written, no file), a later boot with the keyring unreachable + // must NOT generate a new key and rotate identity. Instead it boots + // keyring-locked recovery — the real key is still in the keyring. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + + // First boot: fresh generate into a reachable keyring. + let reachable = FakeIdentityStore::reachable_but_empty(); + resolve_identity_with_store(&reachable, &legacy_path, dir.path()).unwrap(); + assert!(!legacy_path.exists()); + assert!(migration_marker_path(dir.path()).exists()); + + // Second boot: keyring is down. No file + marker present → locked recovery. + let unreachable = FakeIdentityStore::unreachable(); + let resolved = resolve_identity_with_store(&unreachable, &legacy_path, dir.path()).unwrap(); + + assert_eq!( + resolved.recovery, + RecoveryState::KeyringLocked, + "second boot must boot keyring-locked, not generate a fresh key" + ); + // No identity.key was written — nothing new persisted. + assert!(!legacy_path.exists()); +} + +#[test] +fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() { + // Fix 1 correctness on the file-fallback arm: when the keyring write + // FAILS during a fresh generate, the key must land in the `0o600` file + // and the marker must NOT be written — a marker here would wrongly trip + // the next Unreachable boot into failing closed even though the key is + // sitting in the file. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + + let store = FakeIdentityStore::store_failing(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // Key persisted to the file (fallback), and recoverable from it. + let from_file = load_key_file(&legacy_path).unwrap(); + assert_key_eq(&resolved.keys, &from_file); + // No marker: the file is the authoritative store, not the keyring. + assert!(!migration_marker_path(dir.path()).exists()); +} + +// ── New tests for the three defects fixed in this PR ───────────────────── + +#[test] +fn import_persists_to_keyring_reboot_resolves_imported_pubkey() { + // (a) import persists to keyring → simulated reboot resolves the + // imported pubkey. + // + // `persist_identity_to_keyring` is the kernel called by + // `import_identity`. After it succeeds the keyring slot holds the + // imported nsec. A fresh store seeded with that nsec (simulating a + // reboot where the keyring has the value) must resolve to the same key. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + let imported_keys = Keys::generate(); + + // Simulate what import_identity does: persist to keyring. + let store_import = FakeIdentityStore::reachable_but_empty(); + persist_identity_to_keyring(&store_import, &imported_keys, &legacy_path, dir.path()) + .expect("persist_identity_to_keyring must succeed with a reachable store"); + + // Keyring slot now holds the imported nsec. + let stored_nsec = store_import + .slot + .borrow() + .get(IDENTITY_KEY_NAME) + .cloned() + .expect("keyring must hold the imported nsec after persist"); + assert_eq!(stored_nsec, imported_keys.secret_key().to_bech32().unwrap()); + + // Simulated reboot: new store with Present probe, seeded with the stored nsec. + let store_reboot = FakeIdentityStore::present_with(&stored_nsec); + let resolved = resolve_identity_with_store(&store_reboot, &legacy_path, dir.path()).unwrap(); + + // The resolved key is the imported one — identity survives the reboot. + assert_key_eq(&imported_keys, &resolved.keys); + assert_eq!(resolved.recovery, RecoveryState::None); + // No identity.key left on disk (was deleted by persist_identity_to_keyring). + assert!(!legacy_path.exists()); +} + +#[test] +fn present_keyring_with_mismatched_file_adopts_file_key() { + // (b) Present + mismatched identity.key → file's key adopted into + // keyring, no data loss, file removed. + // + // This auto-heals installs already stuck in the re-onboarding loop: + // the keyring holds the shadow key generated at first launch, while + // identity.key holds the user's imported key from a subsequent import + // that only reached the file (pre-fix bug). Resolution must adopt the + // file's key as the user's explicit intent. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + + let keyring_keys = Keys::generate(); + let keyring_nsec = keyring_keys.secret_key().to_bech32().unwrap(); + + // identity.key has a DIFFERENT key — the user's import. + let file_keys = Keys::generate(); + save_key_file(&legacy_path, &file_keys).unwrap(); + + let store = FakeIdentityStore::present_with(&keyring_nsec); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // The file's key (user's explicit import) wins. + assert_key_eq(&file_keys, &resolved.keys); + assert_eq!(resolved.recovery, RecoveryState::None); + + // The keyring now holds the file's key (overwritten with read-back verify). + let file_nsec = file_keys.secret_key().to_bech32().unwrap(); + assert_eq!( + store + .slot + .borrow() + .get(IDENTITY_KEY_NAME) + .map(String::as_str), + Some(file_nsec.as_str()) + ); + + // identity.key was removed after adoption. + assert!(!legacy_path.exists()); + + // Migration marker was written before file removal (crash-safe ordering). + // Without the marker, a later keyring-unreachable boot would see no file + // and no marker and silently generate a fresh key. + let marker_path = migration_marker_path(dir.path()); + assert!( + marker_path.exists(), + "migration marker must exist after mismatched-file adoption" + ); +} + +#[test] +fn present_keyring_mismatched_file_adoption_store_failure_boots_with_file_key() { + // Present + mismatched identity.key + keyring write fails during adoption. + // Boot must succeed with the FILE's key (the user's intent). The file must + // survive on disk because the write was rejected — adoption retries on the + // next boot when the keyring is reachable. The keyring slot must be + // unchanged (shadow nsec still present, not overwritten). + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + + let keyring_keys = Keys::generate(); + let keyring_nsec = keyring_keys.secret_key().to_bech32().unwrap(); + + // identity.key has a DIFFERENT key — the user's import. + let file_keys = Keys::generate(); + save_key_file(&legacy_path, &file_keys).unwrap(); + + let store = FakeIdentityStore::present_with_store_failing(&keyring_nsec); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // File key (user's explicit import) is returned. + assert_key_eq(&file_keys, &resolved.keys); + assert_eq!(resolved.recovery, RecoveryState::None); + + // identity.key must survive — adoption write failed, so it is the only + // durable copy of the imported key until the next-boot retry. + assert!( + legacy_path.exists(), + "identity.key must be kept when keyring adoption write fails" + ); + + // Keyring slot unchanged — write was rejected, no overwrite occurred. + assert_eq!( + store + .slot + .borrow() + .get(IDENTITY_KEY_NAME) + .map(String::as_str), + Some(keyring_nsec.as_str()), + "keyring slot must be unchanged when adoption write fails" + ); +} + +// read-only-dir marker-failure injection is Unix-only: on Windows, +// FILE_ATTRIBUTE_READONLY on a directory does not prevent creating new +// files inside it (it only guards the directory entry itself), so the +// marker write succeeds and the fault cannot be injected this way. +#[cfg(unix)] +#[test] +fn present_keyring_with_mismatched_file_adopts_file_key_marker_failure_keeps_file() { + // (b-fault) Present + mismatched identity.key + marker write fails → + // file MUST NOT be deleted so a later keyring-unreachable boot has a + // fallback. Invariant: keyring-only implies marker exists; if marker + // cannot be written, identity.key is the surviving discriminator. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + + let keyring_keys = Keys::generate(); + let keyring_nsec = keyring_keys.secret_key().to_bech32().unwrap(); + + let file_keys = Keys::generate(); + save_key_file(&legacy_path, &file_keys).unwrap(); + + // Force marker write failure by making the data directory read-only. + // AtomicWriteFile writes a temp file in the same dir then renames it, + // so removing write permission on the dir blocks the write entirely. + let dir_perms_orig = std::fs::metadata(dir.path()).unwrap().permissions(); + let mut dir_perms_ro = dir_perms_orig.clone(); + // unknown_lints: the clippy lint below doesn't exist yet in the pinned + // 1.95 toolchain but does in CI's newer clippy — allow both worlds. + #[allow(unknown_lints)] + #[allow(clippy::permissions_set_readonly_value)] + dir_perms_ro.set_readonly(true); + std::fs::set_permissions(dir.path(), dir_perms_ro).unwrap(); + + let store = FakeIdentityStore::present_with(&keyring_nsec); + // Resolve with the read-only dir; marker write will fail. + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()); + + // Restore perms before any assertions that might panic mid-cleanup. + std::fs::set_permissions(dir.path(), dir_perms_orig).unwrap(); + + // On a read-only fs the file write also fails; we can't even check + // legacy_path reliably there. What matters is that if resolve succeeded + // it returned the file's key, and did NOT delete the file. + if let Ok(resolved) = resolved { + assert_key_eq(&file_keys, &resolved.keys); + assert_eq!(resolved.recovery, RecoveryState::None); + // identity.key must NOT have been deleted — it is the only + // fallback when the marker could not be written. + assert!( + legacy_path.exists(), + "identity.key must be kept when marker write fails after adoption" + ); + } + // If resolve Err'd (e.g. file write also failed) the test still passes — + // we've verified the code doesn't delete the file without a marker. +} + +#[test] +fn reachable_but_empty_with_marker_and_no_file_returns_lost() { + // (d) ReachableButEmpty + marker + no file → "lost" state, NO new key + // generated into the keyring. + // + // The marker says a key was once stored in the keyring. If the keyring + // is now empty (entry deleted externally, new OS login session cleared + // it, etc.) and there is no file fallback, the user's key is truly + // gone. Resolution must NOT silently generate a new identity; it must + // surface a "lost" state so the frontend can prompt re-import. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + // Write the migration marker — a key was once in the keyring. + write_migration_marker(&migration_marker_path(dir.path())).unwrap(); + assert!(!legacy_path.exists()); // no file fallback + + let store = FakeIdentityStore::reachable_but_empty(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // The "lost" flag is set — the frontend must prompt re-import. + assert_eq!( + resolved.recovery, + RecoveryState::Lost, + "identity lost state must be surfaced" + ); + + // No key was persisted to the keyring — the ephemeral key is in-memory + // only and must not overwrite the user's actual (externally lost) key. + assert!( + store.slot.borrow().is_empty(), + "no key must be written to keyring when identity is lost" + ); + + // No identity.key written either — the ephemeral key is transient. + assert!(!legacy_path.exists()); +} + +#[test] +fn persist_imported_identity_falls_back_to_file_on_keyring_failure() { + // `persist_imported_identity_impl` with a failing store returns Ok and + // writes identity.key as a fallback. No migration marker is written — a + // marker here would cause fail-closed on a later Unreachable boot even + // though the key is in the file, not the keyring. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + let imported_keys = Keys::generate(); + + let store = FakeIdentityStore::store_failing(); + + let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path()); + + // The policy core handles the keyring failure — Ok, not Err. + assert!( + result.is_ok(), + "must not propagate keyring failure when file fallback succeeds" + ); + + // Key is recoverable from the file on next boot. + let from_file = load_key_file(&legacy_path).unwrap(); + assert_key_eq(&imported_keys, &from_file); + + // No marker written — the file is the authoritative store, not the keyring. + assert!(!migration_marker_path(dir.path()).exists()); + + // The underlying kernel still propagates keyring failure (low-level + // contract unchanged — the impl layer is what adds the fallback). + let dir2 = tempfile::tempdir().unwrap(); + let path2 = dir2.path().join("identity.key"); + assert!( + persist_identity_to_keyring(&store, &imported_keys, &path2, dir2.path()).is_err(), + "persist_identity_to_keyring must still propagate keyring failure" + ); +} + +#[test] +fn persist_to_keyring_marker_failure_writes_file_when_absent_preserves_invariant() { + // (f) Marker-write failure after a verified keyring write when no + // identity.key exists (e.g. import from a lost state where the file + // was already deleted). The invariant "keyring-only implies marker + // exists" must be preserved: persist_identity_to_keyring must write + // identity.key as a fallback so a later keyring-unreachable boot does + // NOT treat the machine as a fresh install and silently rotate identity. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + assert!(!legacy_path.exists()); // no file — simulates import from lost state + + let imported_keys = Keys::generate(); + // Keyring write succeeds. + let store = FakeIdentityStore::reachable_but_empty(); + + // Force marker write to fail by placing a directory at the marker path. + // AtomicWriteFile::open fails when the target path is a directory. + let marker_path = migration_marker_path(dir.path()); + std::fs::create_dir_all(&marker_path).unwrap(); + + // persist_identity_to_keyring will: store to keyring (succeeds), read- + // back verify (succeeds), attempt write_migration_marker (fails because + // marker_path is a directory), then write identity.key as a fallback. + let result = persist_identity_to_keyring(&store, &imported_keys, &legacy_path, dir.path()); + + // The function returns Ok — the error is handled, not propagated. + assert!( + result.is_ok(), + "persist_identity_to_keyring must not propagate marker failure" + ); + + // identity.key was written as a fallback — invariant preserved. + assert!( + legacy_path.exists(), + "identity.key must exist as fallback when marker write failed and file was absent" + ); + let from_file = load_key_file(&legacy_path).unwrap(); + assert_key_eq(&imported_keys, &from_file); +} + +#[test] +fn present_keyring_same_pubkey_file_no_marker_writes_marker_before_cleanup() { + // Present branch: keyring present + same-pubkey identity.key + NO marker. + // This can arise when persist_identity_to_keyring succeeded at keyring + // write + marker write but the remove_file step failed, then the marker + // was deleted externally — or from any earlier code path that stored to + // the keyring without writing the marker. + // + // The fix: write the marker first (crash-safe ordering), then delete the + // file. Must NOT delete the file while no marker exists. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + assert!(!migration_marker_path(dir.path()).exists()); // no marker + + let keys = Keys::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + // Same key in both keyring and file — stale leftover scenario. + save_key_file(&legacy_path, &keys).unwrap(); + + let store = FakeIdentityStore::present_with(&nsec); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // Keyring key is returned. + assert_key_eq(&keys, &resolved.keys); + assert_eq!(resolved.recovery, RecoveryState::None); + + // Marker must now exist — written before or instead of deleting. + assert!( + migration_marker_path(dir.path()).exists(), + "marker must be written before identity.key is deleted" + ); +} + +#[test] +fn reachable_but_empty_with_marker_and_no_file_returns_lost_ephemeral_not_persisted() { + // Extension of reachable_but_empty_with_marker_and_no_file_returns_lost: + // also verifies that the ephemeral key returned in lost state is NOT + // persisted to the keyring — it must remain in-memory only. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + write_migration_marker(&migration_marker_path(dir.path())).unwrap(); + assert!(!legacy_path.exists()); + + let store = FakeIdentityStore::reachable_but_empty(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_eq!(resolved.recovery, RecoveryState::Lost); + // The ephemeral key must NOT be written to the keyring. + assert!( + store.slot.borrow().is_empty(), + "ephemeral lost key must not be written to keyring" + ); + // No identity.key written either. + assert!(!legacy_path.exists()); + // The ephemeral pubkey is distinct on every call (sanity check). + let store2 = FakeIdentityStore::reachable_but_empty(); + let resolved2 = resolve_identity_with_store(&store2, &legacy_path, dir.path()).unwrap(); + assert_eq!(resolved2.recovery, RecoveryState::Lost); + // Two ephemeral keys are different (probabilistic — collision probability is negligible). + assert_ne!( + resolved.keys.public_key().to_hex(), + resolved2.keys.public_key().to_hex(), + "each lost-state boot produces a distinct ephemeral key" + ); +} + +// ── signing_keys() gate tests ───────────────────────────────────────────── + +#[test] +fn signing_keys_returns_ok_when_normal() { + // When neither identity_lost nor keyring_locked is set, signing_keys() + // must return the live keys and allow signing. + let state = build_app_state(); + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Relaxed); + state + .keyring_locked + .store(false, std::sync::atomic::Ordering::Relaxed); + + let result = state.signing_keys(); + assert!( + result.is_ok(), + "signing_keys() must return Ok when neither flag is set" + ); + // The returned keys must match the stored keys. + let expected = state.keys.lock().unwrap().clone(); + assert_key_eq(&result.unwrap(), &expected); +} + +#[test] +fn signing_keys_returns_err_when_identity_lost() { + // An ephemeral key is held when identity is lost — signing under it would + // publish events with a random identity the user does not own. + let state = build_app_state(); + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Relaxed); + + let result = state.signing_keys(); + assert!( + result.is_err(), + "signing_keys() must return Err when identity_lost is set" + ); + assert!( + result.unwrap_err().contains("recovery mode"), + "error message must mention recovery mode" + ); +} + +#[test] +fn signing_keys_returns_err_when_keyring_locked() { + // The identity key is held in a keyring that is unavailable this boot — + // the stored keys are inaccessible so signing must be blocked. + let state = build_app_state(); + state + .keyring_locked + .store(true, std::sync::atomic::Ordering::Relaxed); + + let result = state.signing_keys(); + assert!( + result.is_err(), + "signing_keys() must return Err when keyring_locked is set" + ); + assert!( + result.unwrap_err().contains("recovery mode"), + "error message must mention recovery mode" + ); +} + +#[test] +fn signing_keys_identity_lost_takes_priority_over_keyring_locked() { + // When both flags are set, identity_lost is checked first and its error + // message is returned (the ephemeral-key case is more specific). + let state = build_app_state(); + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Relaxed); + state + .keyring_locked + .store(true, std::sync::atomic::Ordering::Relaxed); + + let err = state.signing_keys().unwrap_err(); + assert!( + err.contains("recovery mode"), + "both-set must return recovery-mode error: {err}" + ); +} + +// ── Keyring-locked recovery mode tests ─────────────────────────────────── + +#[test] +fn keyring_locked_recovery_ephemeral_never_persisted() { + // Unreachable + marker + no file → KeyringLocked recovery. The ephemeral + // key is held in memory only; no identity.key is created, no keyring + // slot is touched. Fail-closed semantics: no identity is ever rotated. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + write_migration_marker(&migration_marker_path(dir.path())).unwrap(); + assert!(!legacy_path.exists()); + + let store = FakeIdentityStore::unreachable(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_eq!(resolved.recovery, RecoveryState::KeyringLocked); + // Nothing written to disk — ephemeral key is transient. + assert!(!legacy_path.exists()); + // Keyring was never contacted (it is unreachable). + assert!(store.slot.borrow().is_empty()); + assert!(store.deleted.borrow().is_empty()); +} + +#[test] +fn keyring_locked_recovery_distinct_ephemeral_per_boot() { + // Each locked-state boot produces a distinct ephemeral key and persists + // nothing — mirroring the lost-state guarantee. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + write_migration_marker(&migration_marker_path(dir.path())).unwrap(); + assert!(!legacy_path.exists()); + + let store1 = FakeIdentityStore::unreachable(); + let resolved1 = resolve_identity_with_store(&store1, &legacy_path, dir.path()).unwrap(); + assert_eq!(resolved1.recovery, RecoveryState::KeyringLocked); + + let store2 = FakeIdentityStore::unreachable(); + let resolved2 = resolve_identity_with_store(&store2, &legacy_path, dir.path()).unwrap(); + assert_eq!(resolved2.recovery, RecoveryState::KeyringLocked); + + // Two ephemeral keys are different (probabilistic — collision negligible). + assert_ne!( + resolved1.keys.public_key().to_hex(), + resolved2.keys.public_key().to_hex(), + "each locked-state boot produces a distinct ephemeral key" + ); + // Neither boot persisted anything. + assert!(!legacy_path.exists()); +} + +// ── B1: read-back corruption ────────────────────────────────────────────── + +#[test] +fn persist_identity_to_keyring_readback_corrupt_returns_err() { + // B1.1: store() succeeds but load() returns a different valid-format value. + // The read-back verify in persist_identity_to_keyring must detect the + // mismatch and return Err so the caller knows the key was not durably stored. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + + let other_keys = Keys::generate(); + let other_nsec = other_keys.secret_key().to_bech32().unwrap(); + let store = FakeIdentityStore::with_readback_corruption(&other_nsec); + let imported_keys = Keys::generate(); + + let result = persist_identity_to_keyring(&store, &imported_keys, &legacy_path, dir.path()); + + assert!( + result.is_err(), + "must return Err when read-back returns a different value" + ); + assert!( + result.unwrap_err().contains("read-back"), + "error message must mention read-back verify failure" + ); +} + +#[test] +fn persist_imported_identity_impl_readback_corrupt_falls_back_to_file() { + // B1.2: persist_imported_identity_impl with a readback-corrupt store returns + // Ok and writes identity.key as a fallback, and the file holds the original key. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + + let other_keys = Keys::generate(); + let other_nsec = other_keys.secret_key().to_bech32().unwrap(); + let store = FakeIdentityStore::with_readback_corruption(&other_nsec); + let imported_keys = Keys::generate(); + + let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path()); + + assert!( + result.is_ok(), + "must return Ok when file fallback succeeds after readback corruption: {:?}", + result.err() + ); + assert!( + legacy_path.exists(), + "identity.key must be written as fallback" + ); + let from_file = load_key_file(&legacy_path).unwrap(); + assert_key_eq(&imported_keys, &from_file); +} + +// ── B2: corrupt key material recovery ──────────────────────────────────── + +#[test] +fn reachable_but_empty_corrupt_file_generates_fresh() { + // B2.1: ReachableButEmpty probe + corrupt identity.key → migrate_identity_file + // returns Ok(None) for the corrupt file, then generate_and_persist runs and + // stores a fresh valid key in the keyring. No panic; resolve succeeds. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + std::fs::write(&legacy_path, b"this-is-not-a-valid-nsec").unwrap(); + + let store = FakeIdentityStore::reachable_but_empty(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_eq!(resolved.recovery, RecoveryState::None); + // The keyring now holds the fresh key. + let stored_nsec = store + .slot + .borrow() + .get(IDENTITY_KEY_NAME) + .cloned() + .expect("keyring must hold a fresh key after corrupt-file recovery"); + let keyring_keys = Keys::parse(&stored_nsec).expect("keyring value must be a valid nsec"); + assert_key_eq(&resolved.keys, &keyring_keys); +} + +#[test] +fn present_corrupt_keyring_and_corrupt_file_generates_fresh() { + // B2.2: Present probe with a corrupt keyring value AND a corrupt identity.key. + // recover_from_keyring clears the bad entry, migrate_identity_file returns + // Ok(None) for the corrupt file, then generate_and_persist stores a fresh key + // in the keyring. Resolve succeeds; keyring holds the fresh valid key. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + std::fs::write(&legacy_path, b"this-is-not-a-valid-nsec").unwrap(); + + let store = FakeIdentityStore::present_with("not-a-valid-nsec"); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_eq!(resolved.recovery, RecoveryState::None); + // The corrupt keyring entry was cleared. + assert!( + store + .deleted + .borrow() + .contains(&IDENTITY_KEY_NAME.to_string()), + "corrupt keyring entry must be cleared" + ); + // Keyring holds the newly generated valid key. + let stored_nsec = store + .slot + .borrow() + .get(IDENTITY_KEY_NAME) + .cloned() + .expect("keyring must hold a fresh key after double-corrupt recovery"); + let keyring_keys = Keys::parse(&stored_nsec).expect("keyring value must be a valid nsec"); + assert_key_eq(&resolved.keys, &keyring_keys); +} + +// ── B3: Unreachable probe branches ─────────────────────────────────────── + +#[test] +fn unreachable_with_valid_file_resolves_to_file_key() { + // B3.a+b (inputs are indistinguishable at this level): Unreachable + valid + // identity.key → resolves to the file's key. The keyring is never contacted + // and the file is kept on disk (no migration when keyring is down). + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + let file_keys = Keys::generate(); + save_key_file(&legacy_path, &file_keys).unwrap(); + + let store = FakeIdentityStore::unreachable(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_key_eq(&file_keys, &resolved.keys); + assert_eq!(resolved.recovery, RecoveryState::None); + assert!( + legacy_path.exists(), + "identity.key must not be deleted when keyring is unreachable" + ); + assert!( + store.slot.borrow().is_empty(), + "keyring must not be contacted when unreachable" + ); +} + +#[test] +fn unreachable_valid_file_with_marker_resolves_to_file_not_locked_recovery() { + // Unreachable + valid identity.key + marker present → resolves to the file + // key, NOT KeyringLocked recovery. The locked-recovery branch only fires + // when the file is ABSENT; a present file is always used as a direct + // fallback regardless of the marker. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + let file_keys = Keys::generate(); + save_key_file(&legacy_path, &file_keys).unwrap(); + write_migration_marker(&migration_marker_path(dir.path())).unwrap(); + + let store = FakeIdentityStore::unreachable(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_key_eq(&file_keys, &resolved.keys); + assert_eq!( + resolved.recovery, + RecoveryState::None, + "must not enter locked-recovery when a valid file is present" + ); +} + +#[test] +fn unreachable_corrupt_file_generates_fresh() { + // B3.c: Unreachable + corrupt identity.key → load_file_or_generate quarantines + // the corrupt file, generates a fresh key, and saves it to identity.key. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + std::fs::write(&legacy_path, b"this-is-not-a-valid-nsec").unwrap(); + assert!(!migration_marker_path(dir.path()).exists()); + + let store = FakeIdentityStore::unreachable(); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_eq!(resolved.recovery, RecoveryState::None); + // A fresh key was saved to identity.key (quarantine renames the corrupt file). + assert!( + legacy_path.exists(), + "fresh key must be saved to identity.key" + ); + let from_file = load_key_file(&legacy_path).unwrap(); + assert_key_eq(&resolved.keys, &from_file); +} + +// ── B4: marker-write failure variants ──────────────────────────────────── + +#[test] +fn persist_identity_to_keyring_marker_failure_file_fallback_returns_ok() { + // B4.1: marker write fails (data_dir is an existing file, so the marker + // path cannot be created), but the file fallback succeeds — returns Ok and + // identity.key exists and holds the original key. + let dir = tempfile::tempdir().unwrap(); + let key_dir = tempfile::tempdir().unwrap(); + let legacy_path = key_dir.path().join("identity.key"); + assert!(!legacy_path.exists()); + + // Make data_dir a FILE so marker write fails. + let data_dir_file = dir.path().join("data_as_file"); + std::fs::write(&data_dir_file, b"not a dir").unwrap(); + + let store = FakeIdentityStore::reachable_but_empty(); + let imported_keys = Keys::generate(); + + let result = persist_identity_to_keyring(&store, &imported_keys, &legacy_path, &data_dir_file); + + assert!( + result.is_ok(), + "must return Ok when file fallback succeeds despite marker failure: {:?}", + result.err() + ); + assert!( + legacy_path.exists(), + "identity.key must be written as fallback" + ); + let from_file = load_key_file(&legacy_path).unwrap(); + assert_key_eq(&imported_keys, &from_file); +} + +#[test] +fn persist_identity_to_keyring_marker_and_file_failure_returns_err() { + // B4.2: both marker write and file write fail → must return Err (A2 fix). + // data_dir is a FILE (marker write fails); legacy_path is in a non-existent + // subdirectory so AtomicWriteFile::open fails on the file write too. + let dir = tempfile::tempdir().unwrap(); + + let data_dir_file = dir.path().join("data_as_file"); + std::fs::write(&data_dir_file, b"not a dir").unwrap(); + + // Parent directory does not exist → file write fails. + let legacy_path = dir.path().join("nonexistent_subdir").join("identity.key"); + assert!(!legacy_path.exists()); + + let store = FakeIdentityStore::reachable_but_empty(); + let imported_keys = Keys::generate(); + + let result = persist_identity_to_keyring(&store, &imported_keys, &legacy_path, &data_dir_file); + + assert!( + result.is_err(), + "must return Err when both marker write and file write fail" + ); + let err_msg = result.unwrap_err(); + assert!( + err_msg.contains("persisted") || err_msg.contains("marker") || err_msg.contains("file"), + "error message must describe the dual failure: {err_msg}" + ); +} + +#[test] +fn present_keyring_no_file_no_marker_self_heals_marker() { + // B4.3 / A3 coverage: Present(valid) + no identity.key + no migration marker. + // After resolve, the marker must exist (self-healed by A3) so a later + // keyring-Unreachable boot does not treat this as a fresh install. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + assert!(!legacy_path.exists()); + assert!(!migration_marker_path(dir.path()).exists()); + + let keys = Keys::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + let store = FakeIdentityStore::present_with(&nsec); + + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + assert_key_eq(&keys, &resolved.keys); + assert_eq!(resolved.recovery, RecoveryState::None); + assert!( + migration_marker_path(dir.path()).exists(), + "marker must be self-healed by A3 when Present(valid) + no file + no marker" + ); +} + +// ── I1: uncached read-back verify ───────────────────────────────────────── + +#[test] +fn verify_fails_store_does_not_write_marker_or_delete_file() { + // I1: when verify_stored() returns Ok(false) (simulating a backend that + // stores to a cache but does NOT confirm the OS round-trip), + // persist_identity_to_keyring must return Err — the durable state is + // uncertain. The caller must NOT write the migration marker or delete + // identity.key while the durability of the write is unconfirmed. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + let imported_keys = Keys::generate(); + save_key_file(&legacy_path, &imported_keys).unwrap(); + + let store = FakeIdentityStore::with_verify_failing(); + + let result = persist_identity_to_keyring(&store, &imported_keys, &legacy_path, dir.path()); + + // Must return Err — durability of the write was not confirmed. + assert!( + result.is_err(), + "persist_identity_to_keyring must return Err when verify_stored returns false" + ); + let err_msg = result.unwrap_err(); + assert!( + err_msg.contains("read-back"), + "error must mention read-back verify failure: {err_msg}" + ); + + // No migration marker written — the write was not confirmed durable. + assert!( + !migration_marker_path(dir.path()).exists(), + "migration marker must NOT be written when verify_stored fails" + ); + + // identity.key must still exist — must not be deleted without confirmation. + assert!( + legacy_path.exists(), + "identity.key must NOT be deleted when verify_stored fails" + ); +} + +// ── I2: corrupt keyring + marker = Lost recovery ────────────────────────── + +#[test] +fn corrupt_keyring_marker_present_no_file_is_lost() { + // I2: Present(corrupt) + migration marker + no identity.key → the prior + // identity was migrated into the keyring and is now unrecoverable (corrupt + // AND no file backup). Must enter Lost recovery, NOT generate a fresh key. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + write_migration_marker(&migration_marker_path(dir.path())).unwrap(); + assert!(!legacy_path.exists()); + + let store = FakeIdentityStore::present_with("not-a-valid-nsec"); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // Must enter Lost recovery — a prior identity existed and is now unrecoverable. + assert_eq!( + resolved.recovery, + RecoveryState::Lost, + "corrupt keyring + marker + no file must return Lost recovery, not a fresh key" + ); + + // No identity.key written — the ephemeral key is in-memory only. + assert!(!legacy_path.exists()); +} + +#[test] +fn corrupt_keyring_no_marker_no_file_generates_fresh() { + // I2 (counter-case): Present(corrupt) + NO marker + no identity.key → + // genuine first launch with a corrupt keyring, no prior identity to + // protect. generate_and_persist is still the correct last resort. + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + assert!(!legacy_path.exists()); + assert!(!migration_marker_path(dir.path()).exists()); + + let store = FakeIdentityStore::present_with("not-a-valid-nsec"); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + + // No lost recovery — this is a fresh machine with no prior identity. + assert_eq!( + resolved.recovery, + RecoveryState::None, + "corrupt keyring + no marker + no file must generate a fresh key (no prior identity)" + ); + + // A fresh, valid key was stored (keyring or file). + assert!( + store.slot.borrow().contains_key(IDENTITY_KEY_NAME) || legacy_path.exists(), + "a fresh key must be stored in the keyring or the file after generate_and_persist" + ); +} diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs new file mode 100644 index 0000000000..a65b126a4d --- /dev/null +++ b/desktop/src-tauri/src/archive/mod.rs @@ -0,0 +1,673 @@ +//! Local-save archive — Tauri commands for archiving relay messages to a +//! per-identity SQLite database in the Buzz nest. +//! +//! # Architecture +//! +//! Two access proof paths, chosen by event kind: +//! +//! **Persistent scopes** (`channel_h`, `referenced_e`, and `owner_p`+44200): +//! the relay is the source of truth. Candidates are grouped and re-queried via +//! a batched authed `/query`; only events the relay returns are inserted. +//! For kind-44200 (agent turn metrics), content is decrypted at ingest and +//! stored as plaintext JSON — fail-closed (decrypt error → drop). +//! +//! **Ephemeral scope** (`owner_p`, kind 24200 observer frames): the relay +//! never stores these, so `/query` cannot verify them. The relay's REQ-time +//! `#p == authed reader` gate is the access control; local per-frame +//! validation (sig/id + kind + p-tag + agent tag + frame=telemetry + author +//! == agent) is applied fail-closed. + +mod pipeline; +pub mod store; + +use pipeline::{commit_archive, plan_archive, query_buckets}; + +use nostr::Event; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::app_state::AppState; +use crate::managed_agents::nest_dir; +use crate::relay::{query_relay, relay_ws_url_with_override}; + +// ── Constants ─────────────────────────────────────────────────────────────── + +const KIND_AGENT_OBSERVER_FRAME: u16 = 24200; +const KIND_AGENT_TURN_METRIC: u16 = 44200; +const OBSERVER_FRAME_TELEMETRY: &str = "telemetry"; + +// ── DB helpers ─────────────────────────────────────────────────────────────── + +fn open_db() -> Result { + let nest = nest_dir().ok_or("cannot resolve nest directory for archive")?; + let db_path = nest.join("archive").join("archive.db"); + store::open_archive_db(&db_path) +} + +fn identity_pubkey(state: &AppState) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + Ok(keys.public_key().to_hex()) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +async fn run_archive_db_task(task: F) -> Result +where + T: Send + 'static, + F: FnOnce(&Connection) -> Result + Send + 'static, +{ + tokio::task::spawn_blocking(move || { + let conn = open_db()?; + task(&conn) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +// ── Scope type ─────────────────────────────────────────────────────────────── + +/// The three supported archive scope discriminants. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScopeType { + ChannelH, + OwnerP, + ReferencedE, +} + +impl ScopeType { + fn as_str(&self) -> &'static str { + match self { + ScopeType::ChannelH => "channel_h", + ScopeType::OwnerP => "owner_p", + ScopeType::ReferencedE => "referenced_e", + } + } + + fn is_ephemeral(&self) -> bool { + matches!(self, ScopeType::OwnerP) + } +} + +// ── archive_events ─────────────────────────────────────────────────────────── + +/// One event candidate to archive. +#[derive(Debug, Deserialize)] +pub struct ArchiveCandidate { + /// Raw Nostr event JSON. + pub raw_event_json: String, + /// Which save scope this candidate was matched against. The backend + /// re-verifies this — it is never trusted blind. + pub matched_scope: MatchedScope, +} + +/// A scope match assertion from the frontend. +#[derive(Debug, Deserialize)] +pub struct MatchedScope { + pub scope_type: ScopeType, + pub scope_value: String, +} + +/// Result of a batch archive call. +#[derive(Debug, Serialize)] +pub struct ArchiveBatchResult { + /// Events successfully written to the store (event + scope rows). + pub persisted: u32, + /// Events dropped due to access denial or invalid payload (not an error). + pub dropped: u32, +} + +/// Archive a batch of event candidates. +/// +/// - Persistent scopes (`channel_h`, `referenced_e`): grouped by scope, then +/// batch-queried against the relay; only returned events are inserted. +/// - Ephemeral scope (`owner_p`): local validation only (no `/query`). +/// +/// # Send-safety +/// +/// SQLite planning and commit run on the blocking pool. Each phase opens and +/// drops its own `rusqlite::Connection` inside the blocking closure; no +/// connection, transaction, or lock is held across the relay-query await. +#[tauri::command] +pub async fn archive_events( + state: State<'_, AppState>, + candidates: Vec, +) -> Result { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let now = now_secs(); + + // ── Phase 1: plan (blocking SQLite) ───────────────────────────────────── + let plan_identity_pk = identity_pk.clone(); + let plan_relay_url = relay_url.clone(); + let plan = run_archive_db_task(move |conn| { + plan_archive(candidates, &plan_identity_pk, &plan_relay_url, conn) + }) + .await?; + + // ── Phase 2: relay queries (async) ─────────────────────────────────────── + let state_ref: &AppState = &state; + let bucket_results = query_buckets(plan.buckets, state_ref).await; + + // ── Phase 3: persist (blocking SQLite) ────────────────────────────────── + let owner_keys = { + let keys_guard = state.keys.lock().map_err(|e| e.to_string())?; + keys_guard.clone() + // guard drops here, before awaiting the blocking commit task. + }; + let commit_identity_pk = identity_pk.clone(); + let commit_relay_url = relay_url.clone(); + run_archive_db_task(move |conn| { + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + &commit_identity_pk, + &commit_relay_url, + &owner_keys, + now, + conn, + ) + }) + .await +} + +/// Validate an ephemeral observer frame (kind 24200) against ALL local rules. +/// +/// Rules (verbatim from spec): +/// 1. kind == 24200 +/// 2. `#p` contains `identity_pubkey` +/// 3. `agent` tag is present +/// 4. `frame == "telemetry"` (control frames are not archived) +/// 5. event author (pubkey) == agent tag value +/// 6. A matching `owner_p` save-subscription exists AND its `kinds` list +/// includes `24200` (kinds enforcement mirrors the persistent path). +fn validate_ephemeral_frame( + event: &Event, + identity_pk: &str, + scope_value: &str, + conn: &Connection, + sub_identity: &str, + relay_url: &str, +) -> Result<(), String> { + // 1. Kind guard. + if event.kind.as_u16() != KIND_AGENT_OBSERVER_FRAME { + return Err(format!( + "expected kind {KIND_AGENT_OBSERVER_FRAME}, got {}", + event.kind.as_u16() + )); + } + + // 2. `#p` contains current identity. + let p_matches = event.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0] == "p" && s[1] == identity_pk + }); + if !p_matches { + return Err("observer frame #p does not match current identity".into()); + } + + // 3. `agent` tag present. + let agent_value = event + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "agent" { + Some(s[1].clone()) + } else { + None + } + }) + .ok_or_else(|| "observer frame missing `agent` tag".to_string())?; + + // 4. `frame == "telemetry"`. + let frame_value = event + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "frame" { + Some(s[1].clone()) + } else { + None + } + }) + .ok_or_else(|| "observer frame missing `frame` tag".to_string())?; + if frame_value != OBSERVER_FRAME_TELEMETRY { + return Err(format!("expected frame=telemetry, got {frame_value:?}")); + } + + // 5. Event author == agent tag value. + if event.pubkey.to_hex() != agent_value { + return Err("observer frame author does not match agent tag".into()); + } + + // 6. Matching owner_p subscription exists AND kind 24200 is in its kinds list. + let kinds_json = + store::get_subscription_kinds(conn, sub_identity, relay_url, "owner_p", scope_value)? + .ok_or_else(|| format!("no owner_p subscription for scope_value={scope_value:?}"))?; + let allowed_kinds: Vec = serde_json::from_str::>(&kinds_json).unwrap_or_default(); + if !allowed_kinds.contains(&(KIND_AGENT_OBSERVER_FRAME as u64)) { + return Err(format!( + "owner_p subscription kinds {kinds_json:?} does not include {KIND_AGENT_OBSERVER_FRAME}" + )); + } + + Ok(()) +} + +// ── create_save_subscription ───────────────────────────────────────────────── + +/// Create a save subscription after running a per-scope access probe. +/// +/// Probes: +/// - `channel_h`: verify the current user is a member of the channel (kind 39002 +/// `#p` contains our pubkey, or we are the event author / open channel). +/// - `referenced_e`: the referenced event id is currently readable via `/query`. +/// - `owner_p`: restricted to the current identity's own pubkey (v1). +#[tauri::command] +pub async fn create_save_subscription( + state: State<'_, AppState>, + scope_type: ScopeType, + scope_value: String, + kinds: Vec, +) -> Result<(), String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let now = now_secs(); + + // Reject kinds outside the valid NIP-01 range 0..=65535 — the nostr crate + // silently truncates larger values via `v as u16`, which would create + // unmatchable filters. + for &k in &kinds { + if k > u32::from(u16::MAX) { + return Err(format!("kind {k} is out of the valid range 0..=65535")); + } + } + + // Per-scope access probe. + match &scope_type { + ScopeType::ChannelH => { + probe_channel_access(&state, &identity_pk, &scope_value).await?; + } + ScopeType::ReferencedE => { + probe_event_readable(&state, &scope_value).await?; + } + ScopeType::OwnerP => { + // v1: only the current identity's own pubkey is allowed. + if scope_value != identity_pk { + return Err(format!( + "owner_p scope_value must equal current identity pubkey in v1 (got {scope_value:?})" + )); + } + } + } + + let kinds_json = + serde_json::to_string(&kinds).map_err(|e| format!("failed to serialize kinds: {e}"))?; + + let conn = open_db()?; + store::upsert_save_subscription( + &conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + &kinds_json, + now, + ) +} + +/// Probe: the current user has access to `channel_id` (kind 39002 lists them). +async fn probe_channel_access( + state: &AppState, + identity_pk: &str, + channel_id: &str, +) -> Result<(), String> { + // Fetch the channel's members event (kind 39002, #d = channel_id). + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + + // If no members event exists this could be an open channel — try to read + // its metadata (kind 39000) as a fallback proof of readability. + if events.is_empty() { + let meta = query_relay( + state, + &[serde_json::json!({ + "kinds": [39000], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + if meta.is_empty() { + return Err(format!( + "channel {channel_id:?} not found or not accessible" + )); + } + // Open channel — readable, access granted. + return Ok(()); + } + + // Check that the current identity is listed as a member. + let ev = &events[0]; + let is_member = ev.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0] == "p" && s[1] == identity_pk + }); + // Also allow if we are the event author (e.g. the workspace owner who + // published the members event may not be in the `#p` list themselves). + let is_author = ev.pubkey.to_hex() == identity_pk; + + if is_member || is_author { + Ok(()) + } else { + Err(format!( + "current identity is not a member of channel {channel_id:?}" + )) + } +} + +/// Probe: the given event id is currently readable by the current user. +async fn probe_event_readable(state: &AppState, event_id: &str) -> Result<(), String> { + let events = query_relay( + state, + &[serde_json::json!({ + "ids": [event_id], + "limit": 1 + })], + ) + .await?; + + if events.is_empty() { + return Err(format!("event {event_id:?} not found or not accessible")); + } + Ok(()) +} + +// ── merge_save_subscription_kinds ──────────────────────────────────────────── + +/// Atomically merge `kind` into the `owner_p` save subscription for the +/// current identity + relay. +/// +/// Reads the existing `kinds` array, unions in `kind`, and writes back — all +/// inside a single SQLite transaction. This prevents the TOCTOU race where two +/// concurrent seed hooks (observer seed + metric seed) each read an empty row +/// and the last writer clobbers the first. +/// +/// Called by both `useObserverArchiveSeed` (kind 24200) and +/// `useAgentMetricArchiveSeed` (kind 44200) instead of the former +/// list → merge-in-TS → create pattern. +#[tauri::command] +pub async fn merge_save_subscription_kinds( + state: State<'_, AppState>, + kind: u32, +) -> Result<(), String> { + if kind > u32::from(u16::MAX) { + return Err(format!("kind {kind} is out of the valid range 0..=65535")); + } + + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let now = now_secs(); + let owner_pk = identity_pk.clone(); + run_archive_db_task(move |conn| { + store::merge_owner_p_kinds(conn, &identity_pk, &relay_url, &owner_pk, kind, now) + }) + .await +} + +// ── remove_save_subscription_kind ──────────────────────────────────────────── + +/// Atomically remove `kind` from the `owner_p` save subscription for the +/// current identity + relay. +/// +/// Mirrors `merge_save_subscription_kinds`: reads existing kinds, removes +/// `kind`, then deletes the row if the list becomes empty or updates it +/// otherwise. Uses `BEGIN IMMEDIATE` to prevent the same snapshot race as the +/// merge path. +/// +/// Called by both `useObserverArchiveSeed` / `handleObserverToggle` (kind +/// 24200) and the metric equivalents (kind 44200) on toggle-OFF, replacing the +/// former TS-side read-modify-overwrite + whole-row `deleteSaveSubscription` +/// which would drop the *other* kind if `subs` state was stale. +#[tauri::command] +pub async fn remove_save_subscription_kind( + state: State<'_, AppState>, + kind: u32, +) -> Result<(), String> { + if kind > u32::from(u16::MAX) { + return Err(format!("kind {kind} is out of the valid range 0..=65535")); + } + + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let owner_pk = identity_pk.clone(); + run_archive_db_task(move |conn| { + store::remove_owner_p_kind(conn, &identity_pk, &relay_url, &owner_pk, kind) + }) + .await +} + +// ── list_save_subscriptions ────────────────────────────────────────────────── + +/// List all save subscriptions for the current identity + relay. +#[tauri::command] +pub async fn list_save_subscriptions( + state: State<'_, AppState>, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + run_archive_db_task(move |conn| store::list_save_subscriptions(conn, &identity_pk, &relay_url)) + .await +} + +// ── delete_save_subscription ───────────────────────────────────────────────── + +/// Delete a save subscription. +/// +/// Does NOT purge already-archived event data — retention is decoupled in v1. +/// GC of orphaned event rows happens in P4 purge commands, not here. +#[tauri::command] +pub fn delete_save_subscription( + state: State<'_, AppState>, + scope_type: ScopeType, + scope_value: String, +) -> Result { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + store::delete_save_subscription( + &conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + ) +} + +// ── read_archived_events ───────────────────────────────────────────────────── + +// ── read_archived_observer_events_for_channel ──────────────────────────────── + +/// Read a paginated page of archived kind 24200 events scoped to one channel, +/// using the `observer_channel_index` as the primary lookup. +/// +/// The index only contains rows with a known, non-null `channelId` (frames +/// pre-dating the channelId stamp, or rows where decryption failed, are +/// absent from the index and thus absent from every scoped channel view — +/// Will's (a) ruling, 2026-07-08). +/// +/// Returns at most `limit` events (default `DEFAULT_READ_LIMIT`) in +/// newest-first order. Compound cursor `(before_created_at, before_id)` works +/// identically to `read_archived_events`. +#[tauri::command] +pub fn read_archived_observer_events_for_channel( + state: State<'_, AppState>, + channel_id: String, + before_created_at: Option, + before_id: Option, + limit: Option, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + store::read_archived_observer_events_for_channel( + &conn, + &identity_pk, + &relay_url, + &channel_id, + before_created_at, + before_id.as_deref(), + limit.unwrap_or(DEFAULT_READ_LIMIT), + ) +} + +// ── index_observer_channel_id ───────────────────────────────────────────────── + +/// Index one or more archived observer frame ids with their decoded channelId. +/// +/// Called from the TS-side backfill after attempting `decryptObserverEvent`. +/// `channel_id` is `Some` for frames with a non-null channelId; `None` for +/// frames where decryption yielded no channelId or failed entirely. Both cases +/// write a status row so a re-run skips the frame (INSERT OR IGNORE on PK). +/// +/// Idempotent: rows that are already indexed are left unchanged. +#[tauri::command] +pub fn index_observer_channel_id( + state: State<'_, AppState>, + entries: Vec, +) -> Result<(), String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + for entry in &entries { + store::upsert_observer_channel_index( + &conn, + &identity_pk, + &relay_url, + &entry.event_id, + entry.channel_id.as_deref(), + entry.created_at, + )?; + } + Ok(()) +} + +/// A single (event_id, channel_id?, created_at) record used by +/// `index_observer_channel_id`. +/// +/// `channel_id` is `None` for frames where decryption found no channelId or +/// failed; those rows are written to `observer_channel_index` with a NULL +/// channel_id so the frame is treated as processed (no re-decrypt on re-run). +#[derive(Debug, Deserialize)] +pub struct ObserverChannelIndexEntry { + pub event_id: String, + pub channel_id: Option, + pub created_at: i64, +} + +// ── read_unindexed_observer_rows ───────────────────────────────────────────── + +/// Return raw event JSON + id + created_at for all `owner_p` kind 24200 rows +/// that are NOT yet in `observer_channel_index`. +/// +/// The TS-side backfill driver calls this once, decrypts each row, and sends +/// the (id, channelId, created_at) triples back via `index_observer_channel_id`. +/// Together these constitute the one-shot idempotent backfill required by the +/// Slice 1 acceptance criteria (Thufir Pass 4). +#[tauri::command] +pub fn read_unindexed_observer_rows( + state: State<'_, AppState>, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + let rows = store::read_unindexed_observer_rows(&conn, &identity_pk, &relay_url)?; + Ok(rows + .into_iter() + .map(|(id, raw_json, created_at)| RawObserverRow { + id, + raw_json, + created_at, + }) + .collect()) +} + +/// Wire type returned by `read_unindexed_observer_rows`. +#[derive(Debug, Serialize)] +pub struct RawObserverRow { + pub id: String, + pub raw_json: String, + pub created_at: i64, +} + +/// Default page size for `read_archived_events`. +const DEFAULT_READ_LIMIT: i64 = 50; + +/// Read a paginated page of archived events for a scope. +/// +/// Returns at most `limit` events (default `DEFAULT_READ_LIMIT`) in +/// newest-first order. Pass the compound cursor `(before_created_at, +/// before_id)` — both `Some` — from the last row of the previous page to +/// walk backwards. The predicate mirrors `ORDER BY created_at DESC, id DESC` +/// so same-second siblings are never skipped at a page boundary. Pass +/// `None`/`None` to start at the newest end. +/// A returned page shorter than `limit` signals that the archive is exhausted. +/// +/// `kinds` is an optional filter; an empty array means "no kinds matched" +/// (not "all kinds") — callers should pass `null`/`None` when they want all. +/// +/// Note: stored row payloads are not uniform — kind 44200 rows store the raw +/// metric payload JSON, while all other kinds store full Nostr Event JSON. A +/// caller doing `Event::from_json` on an unfiltered read must filter by kind +/// first (today's only reader filters `kinds: [24200]`). +#[tauri::command] +pub async fn read_archived_events( + state: State<'_, AppState>, + scope_type: ScopeType, + scope_value: String, + kinds: Option>, + before_created_at: Option, + before_id: Option, + limit: Option, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let scope_type_str = scope_type.as_str().to_string(); + let read_limit = limit.unwrap_or(DEFAULT_READ_LIMIT); + run_archive_db_task(move |conn| { + store::read_archived_events( + conn, + &identity_pk, + &relay_url, + &scope_type_str, + &scope_value, + kinds.as_deref(), + before_created_at, + before_id.as_deref(), + read_limit, + ) + }) + .await +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod mod_tests; diff --git a/desktop/src-tauri/src/archive/mod_tests.rs b/desktop/src-tauri/src/archive/mod_tests.rs new file mode 100644 index 0000000000..288d2ab34c --- /dev/null +++ b/desktop/src-tauri/src/archive/mod_tests.rs @@ -0,0 +1,1199 @@ +//! Unit and integration tests for `archive/mod.rs`. +//! +//! Kept in a sibling file so `mod.rs` stays under the 1000-line gate; +//! `#[path]`-included from there. + +use super::pipeline::BucketWithResult; +use super::*; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use rusqlite::Connection; +use uuid::Uuid; + +// ── Helpers ────────────────────────────────────────────────────────────── + +fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(super::store::SCHEMA).unwrap(); + conn +} + +fn make_observer_frame(owner_keys: &Keys, agent_keys: &Keys, frame_type: &str) -> Event { + let owner_pk = owner_keys.public_key().to_hex(); + let agent_pk = agent_keys.public_key().to_hex(); + let tags = vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_pk]).unwrap(), + Tag::parse(["frame", frame_type]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(24200), "A".repeat(200)) + .tags(tags) + .sign_with_keys(agent_keys) + .unwrap() +} + +fn add_sub( + conn: &Connection, + identity_pk: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, +) { + store::upsert_save_subscription( + conn, + identity_pk, + relay_url, + scope_type, + scope_value, + kinds_json, + 0, + ) + .unwrap(); +} + +/// Run the full archive pipeline synchronously with a fake relay response. +/// +/// Calls `plan_archive` → injects fake relay events → `commit_archive`. +/// This mirrors `archive_events` without the async relay calls. +fn run_batch_sync( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, + fake_relay_events: Vec, +) -> ArchiveBatchResult { + let owner_keys = Keys::generate(); + run_batch_sync_with_keys( + candidates, + identity_pk, + relay_url, + conn, + fake_relay_events, + &owner_keys, + ) +} + +/// Like `run_batch_sync` but with a specific owner `Keys` for decrypt. +fn run_batch_sync_with_keys( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, + fake_relay_events: Vec, + owner_keys: &Keys, +) -> ArchiveBatchResult { + let plan = plan_archive(candidates, identity_pk, relay_url, conn).unwrap(); + + // Synthesize BucketWithResult from the fake relay response. + let fake_ids: std::collections::HashSet = + fake_relay_events.iter().map(|e| e.id.to_hex()).collect(); + let bucket_results: Vec = plan + .buckets + .into_iter() + .map(|b| BucketWithResult { + scope_type_str: b.scope_type_str, + scope_value: b.scope_value, + allowed_kinds: b.allowed_kinds, + group: b.group, + returned_ids: fake_ids.clone(), + relay_failed: false, + }) + .collect(); + + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + identity_pk, + relay_url, + owner_keys, + 0, + conn, + ) + .unwrap() +} + +fn candidate(event: &Event, scope_type: ScopeType, scope_value: &str) -> ArchiveCandidate { + ArchiveCandidate { + raw_event_json: event.as_json(), + matched_scope: MatchedScope { + scope_type, + scope_value: scope_value.to_string(), + }, + } +} + +// ── Ephemeral validator — individual condition rejection ────────────────── + +#[test] +fn test_ephemeral_validator_accepts_valid_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + assert!( + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url).is_ok() + ); +} + +#[test] +fn test_ephemeral_validator_rejects_wrong_kind() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::TextNote, "hello") + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("kind")); +} + +#[test] +fn test_ephemeral_validator_rejects_missing_p_tag() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), "A".repeat(200)) + .tags(vec![ + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("#p")); +} + +#[test] +fn test_ephemeral_validator_rejects_missing_agent_tag() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), "A".repeat(200)) + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("agent")); +} + +#[test] +fn test_ephemeral_validator_rejects_control_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, "control"); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("telemetry")); +} + +#[test] +fn test_ephemeral_validator_rejects_wrong_author() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let other_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), "A".repeat(200)) + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&other_keys) // wrong signer + .unwrap(); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("author")); +} + +#[test] +fn test_ephemeral_validator_rejects_no_subscription() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Deliberately do NOT add a subscription. + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("owner_p subscription")); +} + +#[test] +fn test_ephemeral_validator_rejects_kind_not_in_subscription() { + // Subscription exists but kinds = [1] (not 24200) — must be rejected. + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); // wrong kinds + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!( + msg.contains("24200"), + "expected kind 24200 in error, got: {msg}" + ); +} + +// ── archive pipeline — persistent path ─────────────────────────────────── + +#[test] +fn test_persistent_channel_h_persists_when_relay_returns_event() { + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns the event (simulates relay proof). + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + + // Confirm the event is in the store. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + let scope_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'channel_h' AND scope_value = ?1", [chan], |r| r.get(0)) + .unwrap(); + assert_eq!(scope_count, 1); +} + +#[test] +fn test_persistent_channel_h_drops_when_relay_does_not_return_event() { + // Relay returns empty — event not proven accessible. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns nothing. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); +} + +#[test] +fn test_persistent_drops_when_no_subscription() { + // No subscription at all — drop before even querying. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + // Intentionally no subscription. + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay would return the event, but no sub → dropped. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); +} + +#[test] +fn test_persistent_drops_kind_not_in_subscription() { + // Subscription is for kind 9 only; event is kind 7 (reaction). + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + // kind 7 reaction — no `h` tag naturally, but relay-returned under scoped filter + let ev = EventBuilder::new(Kind::Reaction, "+") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns the event (simulates relay proof via StoredEvent.channel_id), + // but kind 7 is not in the subscription's kinds list. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); +} + +#[test] +fn test_persistent_h_less_event_persists_when_relay_returns_it() { + // An h-less event (e.g. reaction kind:7) that the relay returns under + // the scoped #h filter (via StoredEvent.channel_id fallback) must be + // persisted. The local tag scanner would have dropped it; the scoped + // filter proof must not. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[7]"); + + // Build a reaction without an `h` tag — local re-derivation would drop it. + let ev = EventBuilder::new(Kind::Reaction, "+") + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns it (relay used StoredEvent.channel_id to match). + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + + // Scope row uses bucket's scope_value, not a local-derived value. + let scope_val: String = conn + .query_row( + "SELECT scope_value FROM archived_event_scopes WHERE scope_type = 'channel_h'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_val, chan); +} + +#[test] +fn test_persistent_referenced_e_persists_when_relay_returns_event() { + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let ref_id = "a".repeat(64); + add_sub( + &conn, + &identity_pk, + relay_url, + "referenced_e", + &ref_id, + "[9]", + ); + + let ev = EventBuilder::new(Kind::Custom(9), "reply") + .tags(vec![Tag::parse(["e", &ref_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ReferencedE, &ref_id)]; + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); +} + +#[test] +fn test_mixed_batch_persisted_and_dropped_counted_exactly() { + // Two channel_h candidates: relay only returns one. dropped must be 1. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev1 = EventBuilder::new(Kind::Custom(9), "msg1") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let ev2 = EventBuilder::new(Kind::Custom(9), "msg2") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![ + candidate(&ev1, ScopeType::ChannelH, chan), + candidate(&ev2, ScopeType::ChannelH, chan), + ]; + + // Fake relay only returns ev1. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev1.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 1); +} + +// ── archive pipeline — ephemeral path ──────────────────────────────────── + +#[test] +fn test_ephemeral_path_persists_valid_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)]; + + // Fake relay returns nothing (not consulted for ephemeral path). + let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); +} + +#[test] +fn test_ephemeral_path_drops_kind_not_in_subscription() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // kinds = [1], not [24200] + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)]; + + let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); +} + +// ── Invalid input dropped ───────────────────────────────────────────────── + +#[test] +fn test_malformed_json_is_dropped() { + let result = Event::from_json("not json at all"); + assert!(result.is_err()); +} + +#[test] +fn test_tampered_event_fails_verify_id() { + let keys = Keys::generate(); + let mut ev_json: serde_json::Value = serde_json::from_str( + &EventBuilder::new(Kind::TextNote, "ok") + .sign_with_keys(&keys) + .unwrap() + .as_json(), + ) + .unwrap(); + ev_json["content"] = serde_json::Value::String("tampered".into()); + let tampered = ev_json.to_string(); + let ev = Event::from_json(&tampered).unwrap(); + assert!(!ev.verify_id()); +} + +// ── F2: out-of-range kind ───────────────────────────────────────────────── + +#[test] +fn test_out_of_range_kind_is_dropped() { + // kind 89736 == 24200 + 65536. The nostr crate truncates it to 24200 + // via `v as u16`, so without the raw-kind check the validator would + // reason about 24200 while the persisted raw_json still says 89736. + // The fix rejects it before Event::from_json. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let owner_pk = &identity_pk; + // Build a valid kind-24200 frame, then mutate only the raw kind to 89736. + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let pk = owner_keys.public_key().to_hex(); + add_sub(&conn, &pk, relay_url, "owner_p", &pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let mut raw: serde_json::Value = serde_json::from_str(&ev.as_json()).unwrap(); + raw["kind"] = serde_json::Value::Number(serde_json::Number::from(24200u64 + 65536)); + let bad_json = raw.to_string(); + + let cand = ArchiveCandidate { + raw_event_json: bad_json, + matched_scope: MatchedScope { + scope_type: ScopeType::OwnerP, + scope_value: pk.clone(), + }, + }; + let result = run_batch_sync(vec![cand], &pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0, "out-of-range kind must be dropped"); + assert_eq!(result.dropped, 1); + // No event row must exist. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + let _ = owner_pk; // silence unused-var +} + +// ── F3: transactional atomicity ─────────────────────────────────────────── + +#[test] +fn test_commit_archive_rolls_back_when_scope_write_would_fail() { + // Verify the split-schema invariant: if we simulate a mid-batch + // failure (by putting the DB into a state where the scope table is + // missing), the event row must NOT survive. + // + // We can't actually make upsert_event_scope fail on a healthy DB, so + // we verify the positive side: with a healthy DB, both rows are always + // written together or neither is. We confirm via run_batch_sync that + // after a full successful commit both tables have matching row counts. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-txn"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev1 = EventBuilder::new(Kind::Custom(9), "msg1") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let ev2 = EventBuilder::new(Kind::Custom(9), "msg2") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![ + candidate(&ev1, ScopeType::ChannelH, chan), + candidate(&ev2, ScopeType::ChannelH, chan), + ]; + let result = run_batch_sync( + cands, + &identity_pk, + relay_url, + &conn, + vec![ev1.clone(), ev2.clone()], + ); + assert_eq!(result.persisted, 2); + assert_eq!(result.dropped, 0); + + // Every event row must have exactly one corresponding scope row. + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let scope_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_event_scopes", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!( + event_count, scope_count, + "every event row must have a matching scope row — split-schema invariant" + ); +} + +// ── Kind-44200 agent-turn-metric archive tests ─────────────────────────── + +fn make_turn_metric_event(owner_keys: &Keys, agent_keys: &Keys) -> Event { + use buzz_core_pkg::agent_turn_metric::{ + encrypt_agent_turn_metric, AgentTurnMetricPayload, TokenCounts, + }; + let owner_pk = owner_keys.public_key().to_hex(); + let payload = AgentTurnMetricPayload { + harness: "test-harness".to_string(), + model: Some("test-model".to_string()), + channel_id: None, + session_id: Some("sess-1".to_string()), + turn_id: Some("turn-1".to_string()), + turn_seq: Some(1), + timestamp: "2026-07-01T00:00:00Z".to_string(), + turn: Some(TokenCounts { + input_tokens: Some(100), + output_tokens: Some(50), + total_tokens: Some(150), + cost_usd: Some(0.001), + cache_read_tokens: None, + cache_write_tokens: None, + }), + cumulative: None, + delta_reliable: true, + stop_reason: None, + }; + let ciphertext = + encrypt_agent_turn_metric(agent_keys, &owner_keys.public_key(), &payload).unwrap(); + let tags = vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(44200), &ciphertext) + .tags(tags) + .sign_with_keys(agent_keys) + .unwrap() +} + +/// A kind-44200 event with `owner_p` scope must route to the persistent +/// (relay-query) path, NOT the ephemeral path. +#[test] +fn test_owner_p_44200_routes_to_persistent_path() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Subscription for kind 44200 under owner_p. + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); + + // Must be in persistent buckets, NOT ephemeral list. + assert_eq!(plan.buckets.len(), 1, "kind-44200 must land in a bucket"); + assert_eq!( + plan.ephemeral.len(), + 0, + "kind-44200 must NOT be on the ephemeral path" + ); + assert_eq!( + plan.buckets[0].scope_type_str, "owner_p", + "bucket scope_type must be owner_p" + ); +} + +/// A kind-24200 event with `owner_p` scope must still route to ephemeral. +#[test] +fn test_owner_p_24200_still_routes_to_ephemeral() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); + + assert_eq!( + plan.buckets.len(), + 0, + "kind-24200 must NOT land in a bucket" + ); + assert_eq!( + plan.ephemeral.len(), + 1, + "kind-24200 must be on the ephemeral path" + ); +} + +/// Decrypt success: plaintext payload JSON is stored, not raw ciphertext. +#[test] +fn test_turn_metric_decrypt_success_stores_plaintext() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let result = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + + assert_eq!(result.persisted, 1, "event must be persisted"); + assert_eq!(result.dropped, 0, "no drops on successful decrypt"); + + // The stored raw_json must be plaintext JSON, not NIP-44 ciphertext. + let raw_json: String = conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + // Plaintext JSON should be a valid object with "harness" key. + let parsed: serde_json::Value = + serde_json::from_str(&raw_json).expect("stored raw_json must be valid JSON"); + assert_eq!( + parsed["harness"], "test-harness", + "stored plaintext must decode to AgentTurnMetricPayload" + ); + // Sanity: must NOT be the original NIP-44 ciphertext (which is not JSON). + assert_ne!( + raw_json, ev.content, + "stored content must differ from original ciphertext" + ); +} + +/// Decrypt fail: event is dropped, nothing written to the store (fail-closed). +#[test] +fn test_turn_metric_decrypt_fail_drops_fail_closed() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let wrong_keys = Keys::generate(); // wrong owner key — decrypt will fail + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Register subscription under owner_pk so the event passes plan-phase, + // but use `wrong_keys` in commit so decrypt fails. + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let result = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &wrong_keys, // wrong key → decrypt fails + ); + + assert_eq!( + result.persisted, 0, + "decrypt failure must not persist the event" + ); + assert_eq!(result.dropped, 1, "decrypt failure must count as dropped"); + + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "no rows must be written to archived_events on decrypt failure" + ); +} + +// ── Real-relay integration tests ────────────────────────────────────────── +// +// Gated on `#[cfg(not(target_os = "windows"))]` because `build_app_state()` +// and `reqwest::Client` pull in native Windows DLLs (SChannel/WinHTTP) that +// are not available in the CI runner, causing STATUS_ENTRYPOINT_NOT_FOUND at +// test-binary launch before any test runs. The guard compiles the whole +// cluster out of the Windows test binary. These tests are `#[ignore]` and +// require a live relay anyway — there is no Windows relay in CI. +// +// Run (Linux/macOS only): +// +// RELAY_URL=ws://localhost:3000 cargo test -p buzz-desktop \ +// archive::tests::real_relay -- --ignored --nocapture +// +// The relay must be running with a Postgres backend (same docker compose +// as the existing e2e suite). Each test creates its own keypair + channel so +// concurrent runs don't interfere. + +#[cfg(not(target_os = "windows"))] +mod real_relay { + use super::*; + use crate::app_state::build_app_state; + use std::path::Path; + + fn relay_ws_url_from_env() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) + } + + fn relay_http_base() -> String { + relay_ws_url_from_env() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() + } + + /// Build a test AppState wired with specific identity keys and relay URL. + /// Mirrors production `archive_events`: the same `query_buckets` call path + /// is exercised, including NIP-98 signing inside `query_relay`. + fn make_test_app_state(keys: Keys, relay_url: &str) -> AppState { + let state = build_app_state(); + *state.keys.lock().unwrap() = keys; + *state.relay_url_override.lock().unwrap() = Some(relay_url.to_string()); + state + } + + /// Submit a signed event to the relay via `POST /events`. + /// The staging relay accepts events without NIP-98; this is only used for + /// test setup (publishing events that the archive pipeline will later query). + async fn submit_event_to_relay(ev: &Event) -> serde_json::Value { + let http = reqwest::Client::new(); + let resp = http + .post(format!("{}/events", relay_http_base())) + .header("X-Pubkey", ev.pubkey.to_hex()) + .header("Content-Type", "application/json") + .body(ev.as_json()) + .send() + .await + .expect("submit event to relay"); + assert!( + resp.status().is_success(), + "relay event submit failed: {}", + resp.status() + ); + resp.json().await.expect("parse submit response") + } + + /// Create an open channel on the relay. Returns the channel UUID string. + async fn create_relay_channel(keys: &Keys) -> String { + let channel_id = Uuid::new_v4().to_string(); + let ev = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["name", &format!("archive-e2e-{channel_id}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + let resp = submit_event_to_relay(&ev).await; + assert!( + resp["accepted"].as_bool().unwrap_or(false), + "channel creation not accepted: {resp}" + ); + channel_id + } + + /// Open a file-backed archive DB at `path` and insert one save subscription. + fn file_db_with_subscription( + path: &Path, + identity_pk: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, + ) { + let conn = store::open_archive_db(path).expect("open file archive db"); + add_sub( + &conn, + identity_pk, + relay_url, + scope_type, + scope_value, + kinds_json, + ); + // conn drops here — file is flushed before the caller reopens it + } + + /// Run plan → real relay query (via the production `query_buckets` path, + /// including NIP-98 signing) → commit, using a file-backed archive DB. + /// + /// Mirrors the open/drop/query/reopen pattern of production `archive_events`: + /// 1. Open DB, run `plan_archive`, drop connection (no conn across `.await`). + /// 2. Call `query_buckets(plan.buckets, &state).await` — NIP-98 signed. + /// 3. Reopen DB for `commit_archive`. + /// + /// Returns `ArchiveBatchResult`; caller reopens the file for row assertions. + async fn run_batch_real_relay( + candidates: Vec, + state: &AppState, + db_path: &Path, + ) -> ArchiveBatchResult { + let identity_pk = state.keys.lock().unwrap().public_key().to_hex(); + let relay_url = crate::relay::relay_ws_url_with_override(state); + + // Phase 1: plan (sync). Connection dropped before any .await. + let plan = { + let conn = store::open_archive_db(db_path).expect("open archive db for plan"); + plan_archive(candidates, &identity_pk, &relay_url, &conn).unwrap() + // conn drops here + }; + + // Phase 2: relay queries (async) — no Connection in scope. + // Uses the real `query_buckets` path: query_relay → NIP-98 signed /query. + let bucket_results = query_buckets(plan.buckets, state).await; + + // Phase 3: persist (sync). Fresh connection, same file. + let conn = store::open_archive_db(db_path).expect("open archive db for commit"); + let owner_keys = state.keys.lock().unwrap().clone(); + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + &identity_pk, + &relay_url, + &owner_keys, + 0, + &conn, + ) + .unwrap() + } + + /// Happy path: publish a kind:9 message to a channel, then run the archive + /// pipeline against the real relay. Asserts exact event + scope rows in a + /// file-backed SQLite archive, reopened after commit to prove persistence. + #[tokio::test] + #[ignore] + async fn test_real_relay_channel_h_happy_path_persists_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + let identity_pk = keys.public_key().to_hex(); + + // Create a channel and publish a kind:9 message. + let channel_id = create_relay_channel(&keys).await; + let msg_ev = EventBuilder::new(Kind::Custom(9), "hello archive") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let submit_resp = submit_event_to_relay(&msg_ev).await; + assert!( + submit_resp["accepted"].as_bool().unwrap_or(false), + "message not accepted: {submit_resp}" + ); + + // Give the relay a moment to persist. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // File-backed archive DB with a channel_h subscription. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "channel_h", + &channel_id, + "[9]", + ); + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 1, "expected 1 persisted, got {result:?}"); + assert_eq!(result.dropped, 0, "expected 0 dropped, got {result:?}"); + + // Reopen the same file to assert exact row counts — proves file-backed persistence. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(event_count, 1, "archived_events should have 1 row"); + + let scope_count: i64 = read_conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes \ + WHERE scope_type = 'channel_h' AND scope_value = ?1", + [&channel_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_count, 1, "archived_event_scopes should have 1 row"); + + // Confirm the stored raw_json round-trips to the original event. + let raw_json: String = read_conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let stored_ev = Event::from_json(&raw_json).unwrap(); + assert_eq!(stored_ev.id.to_hex(), msg_ev.id.to_hex()); + + println!( + "✓ real relay: event {} archived under channel_h:{}", + msg_ev.id.to_hex(), + channel_id + ); + println!(" archived_events: {event_count} row(s)"); + println!(" archived_event_scopes: {scope_count} row(s)"); + } + + /// Kind-mismatch drop: subscription allows only kinds=[1059] but we publish a kind:9 + /// channel message. The relay stores the event, but the archive's kind filter drops it + /// because 9 ∉ {1059}. + #[tokio::test] + #[ignore] + async fn test_real_relay_kind_mismatch_drops_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + let identity_pk = keys.public_key().to_hex(); + + let channel_id = create_relay_channel(&keys).await; + // Publish a kind:9 channel message — relay accepts it, but the subscription's + // kind filter is [1059] so the archive pipeline must drop it. + let msg_ev = EventBuilder::new(Kind::Custom(9), "kind-mismatch-msg") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let submit_resp = submit_event_to_relay(&msg_ev).await; + assert!( + submit_resp["accepted"].as_bool().unwrap_or(false), + "message not accepted: {submit_resp}" + ); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // Subscription allows kinds=[1059] only — kind:9 must be dropped. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "channel_h", + &channel_id, + "[1059]", + ); + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 0, "kind-mismatch: should be dropped"); + assert_eq!(result.dropped, 1, "kind-mismatch: drop count should be 1"); + + // Belt-and-suspenders: confirm the on-disk archive is genuinely empty. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "nothing should be archived on kind-mismatch" + ); + + println!( + "✓ real relay: kind-mismatch correctly dropped kind:9 event (subscription is kinds=[1059])" + ); + } + + /// No-subscription drop: event arrives but no save_subscription row exists. + #[tokio::test] + #[ignore] + async fn test_real_relay_no_subscription_drops_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + + let channel_id = create_relay_channel(&keys).await; + let msg_ev = EventBuilder::new(Kind::Custom(9), "should be dropped") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + submit_event_to_relay(&msg_ev).await; + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // Empty file-backed archive DB — no subscription row. + // plan_archive drops the whole group because no subscription matches. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + store::open_archive_db(&db_path).expect("init empty archive db"); + // conn from init drops here; DB file exists but has no subscription rows + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 0, "no-sub: should be dropped"); + assert_eq!(result.dropped, 1, "no-sub: drop count should be 1"); + + // Belt-and-suspenders: confirm the on-disk archive is genuinely empty. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "nothing should be archived on no-subscription" + ); + + println!("✓ real relay: no-subscription correctly dropped event"); + } + + /// Owner_p ephemeral path: a locally-built valid 24200 frame is archived + /// without a relay query (ephemeral events are never stored on the relay). + #[tokio::test] + #[ignore] + async fn test_real_relay_owner_p_ephemeral_path_persists_valid_frame() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(owner_keys.clone(), &relay_url); + let identity_pk = owner_keys.public_key().to_hex(); + + // Build a valid kind:24200 observer frame addressed to the owner. + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + + // File-backed archive DB with an owner_p subscription for kind 24200. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "owner_p", + &identity_pk, + "[24200]", + ); + + // owner_p candidates bypass the relay entirely — query_buckets gets an + // empty bucket list and the ephemeral path handles the frame locally. + let cands = vec![candidate(&ev, ScopeType::OwnerP, &identity_pk)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!( + result.persisted, 1, + "owner_p: valid frame should be persisted" + ); + assert_eq!(result.dropped, 0, "owner_p: nothing should be dropped"); + + // Reopen the same file to assert exact row counts. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(event_count, 1); + + let scope_count: i64 = read_conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'owner_p'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_count, 1); + + // Confirm the stored raw_json round-trips to the original frame. + let raw_json: String = read_conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let stored_ev = Event::from_json(&raw_json).unwrap(); + assert_eq!(stored_ev.id.to_hex(), ev.id.to_hex()); + + println!( + "✓ real relay: owner_p ephemeral frame {} archived", + ev.id.to_hex() + ); + println!(" archived_events: {event_count} row(s)"); + println!(" archived_event_scopes: {scope_count} row(s)"); + } +} diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs new file mode 100644 index 0000000000..2bd149dceb --- /dev/null +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -0,0 +1,454 @@ +//! Archive pipeline — three-phase plan/query/commit split. +//! +//! Separated from `mod.rs` to keep that file under the 1000-line gate. +//! +//! # Send-safety +//! +//! `rusqlite::Connection` is `!Send`. No `&Connection` borrow crosses an +//! `.await` point in any function here. Phase 1 (`plan_archive`) and Phase 3 +//! (`commit_archive`) are sync; Phase 2 (`query_buckets`) is async and never +//! holds a `Connection` reference. + +use nostr::{Event, JsonUtil}; +use rusqlite::Connection; + +use crate::app_state::AppState; +use crate::relay::query_relay; + +use super::{store, validate_ephemeral_frame, ArchiveBatchResult, ArchiveCandidate, MatchedScope}; + +// ── Private helpers ─────────────────────────────────────────────────────────── + +/// Extract the raw `kind` integer from an event JSON string and return it as +/// `Some(u64)` only if it is in the valid NIP-01 range `0..=65535`. +/// +/// Returns `None` for malformed JSON, a missing `kind` field, a non-integer +/// `kind`, or any value outside `0..=65535`. Used to detect the `nostr` +/// crate's silent `v as u16` truncation before deserialization. +fn raw_kind_value(raw: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(raw).ok()?; + let kind = v.get("kind")?.as_u64()?; + if kind > 65535 { + return None; + } + Some(kind) +} + +// ── Private types ──────────────────────────────────────────────────────────── + +/// A parsed, sig-verified candidate ready for further processing. +pub(super) struct Parsed { + pub(super) event: Event, + pub(super) raw_json: String, + pub(super) matched_scope: MatchedScope, +} + +/// One scope bucket: a set of candidates that share a scope type+value, +/// with the relay filter already built and the subscription kinds loaded. +pub(super) struct Bucket { + pub(super) scope_type_str: String, + pub(super) scope_value: String, + pub(super) allowed_kinds: Vec, + pub(super) filter: serde_json::Value, + pub(super) group: Vec, +} + +/// Output of the sync planning phase. +pub(super) struct ArchivePlan { + pub(super) buckets: Vec, + pub(super) ephemeral: Vec, + /// Events already accounted as dropped during planning (no subscription, + /// unknown scope type, parse failure, bad sig). + pub(super) pre_dropped: u32, +} + +/// A bucket with the relay's response attached. +pub(super) struct BucketWithResult { + pub(super) scope_type_str: String, + pub(super) scope_value: String, + pub(super) allowed_kinds: Vec, + pub(super) group: Vec, + /// Event ids returned by the relay for the scoped filter. + pub(super) returned_ids: std::collections::HashSet, + /// True if the relay query failed (network error); entire group dropped. + pub(super) relay_failed: bool, +} + +// ── Phase 1 ────────────────────────────────────────────────────────────────── + +/// Phase 1 (sync): parse all candidates, group persistent ones into per-scope +/// buckets, and load the subscription kinds for each bucket. +/// +/// Returns an [`ArchivePlan`] with no `&Connection` remaining — safe to hold +/// across `.await`. +pub(super) fn plan_archive( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, +) -> Result { + let mut persistent_raw: Vec = Vec::new(); + let mut ephemeral: Vec = Vec::new(); + let mut pre_dropped: u32 = 0; + + for cand in candidates { + // Range-validate raw kind before nostr::Event normalizes it. + // `nostr 0.44.3` `Kind::deserialize` does `v as u16`, which silently + // truncates e.g. `89736` (= 24200 + 65536) to `24200`. We reject any + // raw `kind` outside 0..=65535 so the validator never reasons about a + // truncated kind while persisting the original out-of-range value. + let raw_kind = match raw_kind_value(&cand.raw_event_json) { + Some(k) => k, + None => { + pre_dropped += 1; + continue; + } + }; + + let event = match Event::from_json(&cand.raw_event_json) { + Ok(e) => e, + Err(_) => { + pre_dropped += 1; + continue; + } + }; + + // Assert the deserialized kind matches the raw value (paranoia check). + if event.kind.as_u16() as u64 != raw_kind { + pre_dropped += 1; + continue; + } + + if !event.verify_id() || !event.verify_signature() { + pre_dropped += 1; + continue; + } + + // owner_p scope splits by kind: + // kind 24200 (observer frames) → ephemeral path (relay never stores them). + // kind 44200 (turn metrics) → persistent path (relay stores, #p-gated). + // Any other kind under owner_p follows the same ephemeral path as 24200 + // (conservative default for unknowns). + let is_ephemeral = cand.matched_scope.scope_type.is_ephemeral() + && raw_kind != super::KIND_AGENT_TURN_METRIC as u64; + + if is_ephemeral { + ephemeral.push(Parsed { + event, + raw_json: cand.raw_event_json, + matched_scope: cand.matched_scope, + }); + } else { + persistent_raw.push(Parsed { + event, + raw_json: cand.raw_event_json, + matched_scope: cand.matched_scope, + }); + } + } + + // Group persistent candidates by (scope_type, scope_value). + use std::collections::HashMap; + let mut scope_groups: HashMap<(String, String), Vec> = HashMap::new(); + for p in persistent_raw { + let key = ( + p.matched_scope.scope_type.as_str().to_string(), + p.matched_scope.scope_value.clone(), + ); + scope_groups.entry(key).or_default().push(p); + } + + let mut buckets: Vec = Vec::with_capacity(scope_groups.len()); + for ((scope_type_str, scope_value), mut group) in scope_groups { + // No subscription → drop the whole group. + let kinds_json = match store::get_subscription_kinds( + conn, + identity_pk, + relay_url, + &scope_type_str, + &scope_value, + )? { + Some(k) => k, + None => { + pre_dropped += group.len() as u32; + continue; + } + }; + + let allowed_kinds: Vec = + serde_json::from_str::>(&kinds_json).unwrap_or_default(); + + // Deduplicate by event id within the bucket. + let mut seen = std::collections::HashSet::new(); + group.retain(|p| seen.insert(p.event.id.to_hex())); + + let ids: Vec = group.iter().map(|p| p.event.id.to_hex()).collect(); + + // Build a *scoped* relay filter: ids + scope tag + kinds. + let filter = match scope_type_str.as_str() { + "channel_h" => serde_json::json!({ + "ids": ids, + "#h": [&scope_value], + "kinds": allowed_kinds, + }), + "referenced_e" => serde_json::json!({ + "ids": ids, + "#e": [&scope_value], + "kinds": allowed_kinds, + }), + "owner_p" => serde_json::json!({ + "ids": ids, + "#p": [&scope_value], + "kinds": allowed_kinds, + }), + _ => { + pre_dropped += group.len() as u32; + continue; + } + }; + + buckets.push(Bucket { + scope_type_str, + scope_value, + allowed_kinds, + filter, + group, + }); + } + + Ok(ArchivePlan { + buckets, + ephemeral, + pre_dropped, + }) +} + +// ── Phase 2 ────────────────────────────────────────────────────────────────── + +/// Phase 2 (async): fire one relay query per bucket and collect results. +/// +/// `state` is `&AppState` — a `Copy` reference — so no `!Send` value is held +/// across `.await`. +pub(super) async fn query_buckets(buckets: Vec, state: &AppState) -> Vec { + let mut results: Vec = Vec::with_capacity(buckets.len()); + for bucket in buckets { + let (returned_ids, relay_failed) = match query_relay(state, &[bucket.filter]).await { + Ok(evs) => (evs.iter().map(|e| e.id.to_hex()).collect(), false), + Err(_) => (std::collections::HashSet::new(), true), + }; + results.push(BucketWithResult { + scope_type_str: bucket.scope_type_str, + scope_value: bucket.scope_value, + allowed_kinds: bucket.allowed_kinds, + group: bucket.group, + returned_ids, + relay_failed, + }); + } + results +} + +// ── Phase 3 ────────────────────────────────────────────────────────────────── + +/// Phase 3 (sync): apply relay results and write accepted events to the store. +/// +/// All event + scope upserts run inside a single SQLite transaction: either +/// every write in the batch commits or none do. This preserves the invariant +/// that every `archived_events` row has at least one matching `archived_event_scopes` +/// row — a partial failure can never leave an orphaned event with no scope proof. +#[allow(clippy::too_many_arguments)] +pub(super) fn commit_archive( + bucket_results: Vec, + ephemeral: Vec, + pre_dropped: u32, + identity_pk: &str, + relay_url: &str, + owner_keys: &nostr::Keys, + now: i64, + conn: &Connection, +) -> Result { + let mut persisted: u32 = 0; + let mut dropped: u32 = pre_dropped; + + // Collect writes; count drops first, then execute inside a single + // transaction so event and scope rows are always committed atomically. + // + // raw_json is owned so kind-44200 rows can store decrypted plaintext + // instead of the original NIP-44 ciphertext. + struct WriteRow { + eid: String, + kind: i64, + pubkey: String, + created_at: i64, + raw_json: String, + scope_type: String, + scope_value: String, + } + let mut writes: Vec = Vec::new(); + + // ── Persistent path ────────────────────────────────────────────────────── + for result in &bucket_results { + if result.relay_failed { + dropped += result.group.len() as u32; + continue; + } + + for p in &result.group { + let eid = p.event.id.to_hex(); + + // Relay proof: event was returned for the scoped filter. + if !result.returned_ids.contains(&eid) { + dropped += 1; + continue; + } + + // Kind enforcement: event.kind must be in the subscription's list. + if !result + .allowed_kinds + .contains(&(p.event.kind.as_u16() as u64)) + { + dropped += 1; + continue; + } + + // For kind-44200 (agent turn metrics): decrypt at ingest and store + // the plaintext payload JSON so token-usage calculators can read + // the archive without needing the owner key. Fail-closed: if + // decrypt fails for any reason, drop the event — never store + // ciphertext or partial output. + let stored_json = + if p.event.kind.as_u16() as u64 == super::KIND_AGENT_TURN_METRIC as u64 { + match buzz_core_pkg::agent_turn_metric::decrypt_agent_turn_metric( + owner_keys, &p.event, + ) { + Ok(payload) => match serde_json::to_string(&payload) { + Ok(json) => json, + Err(_) => { + dropped += 1; + continue; + } + }, + Err(_) => { + dropped += 1; + continue; + } + } + } else { + p.raw_json.clone() + }; + + // The relay returning this event for {ids, #h/#e/#p, kinds} IS the + // proof of scope membership. Use scope_value directly; no local + // tag re-derivation (which would incorrectly drop h-less events + // matched via the relay's StoredEvent.channel_id fallback). + writes.push(WriteRow { + eid, + kind: p.event.kind.as_u16() as i64, + pubkey: p.event.pubkey.to_hex(), + created_at: p.event.created_at.as_secs() as i64, + raw_json: stored_json, + scope_type: result.scope_type_str.clone(), + scope_value: result.scope_value.clone(), + }); + } + } + + // ── Ephemeral path (owner_p) ───────────────────────────────────────────── + // Fully local validation — no relay query. + let mut validated_ephemeral: Vec<(String, &Parsed)> = Vec::new(); + for p in &ephemeral { + match validate_ephemeral_frame( + &p.event, + identity_pk, + &p.matched_scope.scope_value, + conn, + identity_pk, + relay_url, + ) { + Ok(()) => validated_ephemeral.push((p.event.id.to_hex(), p)), + Err(_) => { + dropped += 1; + } + } + } + + // ── Commit all writes atomically ───────────────────────────────────────── + if !writes.is_empty() || !validated_ephemeral.is_empty() { + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("failed to begin archive transaction: {e}"))?; + + for w in &writes { + store::upsert_archived_event( + &tx, + identity_pk, + relay_url, + &w.eid, + w.kind, + &w.pubkey, + w.created_at, + &w.raw_json, + now, + )?; + store::upsert_event_scope( + &tx, + identity_pk, + relay_url, + &w.eid, + &w.scope_type, + &w.scope_value, + now, + )?; + persisted += 1; + } + + for (eid, p) in &validated_ephemeral { + store::upsert_archived_event( + &tx, + identity_pk, + relay_url, + eid, + p.event.kind.as_u16() as i64, + &p.event.pubkey.to_hex(), + p.event.created_at.as_secs() as i64, + &p.raw_json, + now, + )?; + store::upsert_event_scope( + &tx, + identity_pk, + relay_url, + eid, + "owner_p", + &p.matched_scope.scope_value, + now, + )?; + + // Index at ingest: attempt to decrypt and extract channelId. + // Write a status row regardless of outcome so backfill never + // re-processes this frame (INSERT OR IGNORE on PK is a no-op if + // the row is already present from a prior run). + let channel_id_for_index: Option = + buzz_core_pkg::observer::decrypt_observer_payload::( + owner_keys, &p.event, + ) + .ok() + .and_then(|v| v.get("channelId")?.as_str().map(|s| s.to_owned())); + store::upsert_observer_channel_index( + &tx, + identity_pk, + relay_url, + eid, + channel_id_for_index.as_deref(), + p.event.created_at.as_secs() as i64, + )?; + + persisted += 1; + } + + tx.commit() + .map_err(|e| format!("failed to commit archive transaction: {e}"))?; + } + + Ok(ArchiveBatchResult { persisted, dropped }) +} diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs new file mode 100644 index 0000000000..ae0ef92e4b --- /dev/null +++ b/desktop/src-tauri/src/archive/store.rs @@ -0,0 +1,804 @@ +//! Local SQLite archive store for saved relay messages. +//! +//! Three tables: +//! - `archived_events` — one raw event row per (identity, relay, event_id) +//! - `archived_event_scopes` — N scope membership rows per raw event (many-to-many) +//! - `save_subscriptions` — which scopes the user has subscribed to save +//! +//! WAL + `busy_timeout=5000` matches `managed_agents/retention.rs`. +//! Raw event rows are GC'd when their last scope row is deleted. + +use std::path::Path; + +use rusqlite::{params, Connection, OptionalExtension}; +use std::time::{Duration, Instant}; + +// ── Schema ───────────────────────────────────────────────────────────────── + +pub(super) const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS archived_events ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + id TEXT NOT NULL, + kind INTEGER NOT NULL, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + raw_json TEXT NOT NULL, + archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) +); + +CREATE TABLE IF NOT EXISTS archived_event_scopes ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + id TEXT NOT NULL, + scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, + archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id, scope_type, scope_value) +); + +CREATE TABLE IF NOT EXISTS save_subscriptions ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, + kinds TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, scope_type, scope_value) +); + +-- Processing-status index for kind 24200 (observer) frames. +-- +-- Every examined observer row — whether its channelId was successfully +-- decrypted or not — gets exactly one row here keyed by (identity, relay, id). +-- `channel_id` is nullable: +-- NOT NULL → frame was attributed to a channel; visible in that channel's feed. +-- NULL → frame was examined but had no channelId (pre-stamp or decrypt +-- failure); excluded from all scoped views per Will's (a) ruling +-- (2026-07-08), but the row exists so a re-run is a no-op. +-- +-- Scoped reads filter `channel_id = ?`; NULL rows never match, staying hidden. +CREATE TABLE IF NOT EXISTS observer_channel_index ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + id TEXT NOT NULL, + channel_id TEXT, + created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) +); +CREATE INDEX IF NOT EXISTS idx_observer_channel + ON observer_channel_index (identity_pubkey, relay_url, channel_id, created_at DESC, id DESC); + +-- One-row migration state table: tracks which idempotent migrations have run. +CREATE TABLE IF NOT EXISTS archive_migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL +); +"; + +// ── Open / init ───────────────────────────────────────────────────────────── + +/// Open (or create) the archive database at the given path. +/// +/// Applies WAL journaling and `busy_timeout=5000` on every connection, +/// matching `managed_agents/retention.rs`. Creates all three tables if they +/// don't already exist. +pub fn open_archive_db(path: &Path) -> Result { + // Ensure the parent directory exists so `Connection::open` doesn't fail. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create archive dir: {e}"))?; + } + + let conn = Connection::open(path).map_err(|e| format!("failed to open archive db: {e}"))?; + + conn.pragma_update(None, "busy_timeout", 5000) + .map_err(|e| format!("failed to set busy_timeout: {e}"))?; + set_wal_mode(&conn)?; + + conn.execute_batch(SCHEMA) + .map_err(|e| format!("failed to initialize archive schema: {e}"))?; + + Ok(conn) +} + +fn set_wal_mode(conn: &Connection) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match conn.pragma_update(None, "journal_mode", "WAL") { + Ok(()) => return Ok(()), + Err(error) if sqlite_is_busy(&error) && Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(25)); + } + Err(error) => return Err(format!("failed to set WAL mode: {error}")), + } + } +} + +fn sqlite_is_busy(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(inner, _) + if matches!( + inner.code, + rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked + ) + ) +} + +// ── Save subscriptions ────────────────────────────────────────────────────── + +/// A save subscription row. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SaveSubscription { + pub identity_pubkey: String, + pub relay_url: String, + pub scope_type: String, + pub scope_value: String, + /// JSON-encoded integer array, e.g. `[1,6,39000]`. + pub kinds: String, + pub created_at: i64, +} + +/// Insert or replace a save subscription. `kinds` must be a JSON int array. +pub fn upsert_save_subscription( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, + now: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO save_subscriptions + (identity_pubkey, relay_url, scope_type, scope_value, kinds, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT (identity_pubkey, relay_url, scope_type, scope_value) + DO UPDATE SET kinds = excluded.kinds", + params![ + identity_pubkey, + relay_url, + scope_type, + scope_value, + kinds_json, + now + ], + ) + .map_err(|e| format!("failed to upsert save subscription: {e}"))?; + Ok(()) +} + +/// List all save subscriptions for the given identity + relay. +pub fn list_save_subscriptions( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT identity_pubkey, relay_url, scope_type, scope_value, kinds, created_at + FROM save_subscriptions + WHERE identity_pubkey = ?1 AND relay_url = ?2 + ORDER BY created_at ASC", + ) + .map_err(|e| format!("prepare list_save_subscriptions: {e}"))?; + + let rows = stmt + .query_map(params![identity_pubkey, relay_url], |row| { + Ok(SaveSubscription { + identity_pubkey: row.get(0)?, + relay_url: row.get(1)?, + scope_type: row.get(2)?, + scope_value: row.get(3)?, + kinds: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .map_err(|e| format!("query list_save_subscriptions: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("read list_save_subscriptions row: {e}")) +} + +/// Delete a save subscription. Does NOT purge archived event data (retention +/// is decoupled in v1). Returns `true` if a row was deleted. +pub fn delete_save_subscription( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, +) -> Result { + let affected = conn + .execute( + "DELETE FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = ?3 + AND scope_value = ?4", + params![identity_pubkey, relay_url, scope_type, scope_value], + ) + .map_err(|e| format!("failed to delete save subscription: {e}"))?; + Ok(affected > 0) +} + +/// Return true if a matching save subscription exists for the given scope. +#[allow(dead_code)] +pub fn has_save_subscription( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, +) -> Result { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = ?3 + AND scope_value = ?4", + params![identity_pubkey, relay_url, scope_type, scope_value], + |row| row.get(0), + ) + .map_err(|e| format!("failed to check save subscription: {e}"))?; + Ok(count > 0) +} + +/// Atomically merge `new_kind` into the `owner_p` save subscription for the +/// given identity + relay + scope_value. +/// +/// Reads the current `kinds` array, unions in `new_kind`, and writes back — +/// all inside a single `BEGIN IMMEDIATE` SQLite transaction. `IMMEDIATE` +/// acquires the write lock at `BEGIN` (before the `SELECT`), so a second +/// concurrent caller blocks on `busy_timeout` (5000 ms, set by +/// `open_archive_db`) and then reads the first caller's committed row. This +/// guarantees the union is complete; a `DEFERRED` transaction would let two +/// concurrent callers both read the empty snapshot and produce a +/// `BUSY_SNAPSHOT` failure on the losing writer. +/// +/// If no row exists yet it is created with `[new_kind]`. `now` is used as +/// `created_at` only on insert (the conflict clause never updates it). +pub fn merge_owner_p_kinds( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_value: &str, + new_kind: u32, + now: i64, +) -> Result<(), String> { + // BEGIN IMMEDIATE takes the write lock before the SELECT so concurrent + // callers serialize here rather than racing to a BUSY_SNAPSHOT error. + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(|e| format!("merge_owner_p_kinds begin immediate: {e}"))?; + + let result = (|| -> Result<(), String> { + // Read the current kinds, if any. + let existing_json: Option = conn + .query_row( + "SELECT kinds FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = 'owner_p' + AND scope_value = ?3", + params![identity_pubkey, relay_url, scope_value], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("merge_owner_p_kinds read: {e}"))?; + + // Parse, union, re-serialize. + let mut kinds: Vec = match existing_json { + Some(ref json) => serde_json::from_str(json).unwrap_or_default(), + None => vec![], + }; + if !kinds.contains(&new_kind) { + kinds.push(new_kind); + } + let kinds_json = serde_json::to_string(&kinds) + .map_err(|e| format!("merge_owner_p_kinds serialize: {e}"))?; + + // Upsert — INSERT on first call, UPDATE kinds on subsequent. + conn.execute( + "INSERT INTO save_subscriptions + (identity_pubkey, relay_url, scope_type, scope_value, kinds, created_at) + VALUES (?1, ?2, 'owner_p', ?3, ?4, ?5) + ON CONFLICT (identity_pubkey, relay_url, scope_type, scope_value) + DO UPDATE SET kinds = excluded.kinds", + params![identity_pubkey, relay_url, scope_value, kinds_json, now], + ) + .map_err(|e| format!("merge_owner_p_kinds upsert: {e}"))?; + + Ok(()) + })(); + + if result.is_ok() { + conn.execute_batch("COMMIT") + .map_err(|e| format!("merge_owner_p_kinds commit: {e}"))?; + } else { + // Best-effort rollback; ignore the rollback error to surface the + // original error to the caller. + let _ = conn.execute_batch("ROLLBACK"); + } + + result +} + +/// Atomically remove `kind` from the `owner_p` save subscription for the +/// given identity + relay + scope_value. +/// +/// Reads the current `kinds` array, removes `kind` if present, then: +/// - if the resulting list is **empty**, deletes the `owner_p` row entirely +/// (keeping parity with the UI behavior where the last kind off removes the +/// subscription row). +/// - otherwise, updates `kinds` to the reduced list. +/// +/// Uses `BEGIN IMMEDIATE` for the same reason as `merge_owner_p_kinds`: the +/// write lock is acquired before the SELECT so concurrent callers serialize +/// rather than racing to a `BUSY_SNAPSHOT` error. +/// +/// No-op (Ok) if the row does not exist or the kind is not present. +pub fn remove_owner_p_kind( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_value: &str, + kind: u32, +) -> Result<(), String> { + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(|e| format!("remove_owner_p_kind begin immediate: {e}"))?; + + let result = (|| -> Result<(), String> { + // Read the current kinds, if any. + let existing_json: Option = conn + .query_row( + "SELECT kinds FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = 'owner_p' + AND scope_value = ?3", + params![identity_pubkey, relay_url, scope_value], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("remove_owner_p_kind read: {e}"))?; + + let existing_json = match existing_json { + Some(j) => j, + // Row doesn't exist — nothing to remove. + None => return Ok(()), + }; + + let mut kinds: Vec = serde_json::from_str(&existing_json).unwrap_or_default(); + kinds.retain(|&k| k != kind); + + if kinds.is_empty() { + // Last kind removed — delete the row entirely. + conn.execute( + "DELETE FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = 'owner_p' + AND scope_value = ?3", + params![identity_pubkey, relay_url, scope_value], + ) + .map_err(|e| format!("remove_owner_p_kind delete: {e}"))?; + } else { + let kinds_json = serde_json::to_string(&kinds) + .map_err(|e| format!("remove_owner_p_kind serialize: {e}"))?; + conn.execute( + "UPDATE save_subscriptions + SET kinds = ?4 + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = 'owner_p' + AND scope_value = ?3", + params![identity_pubkey, relay_url, scope_value, kinds_json], + ) + .map_err(|e| format!("remove_owner_p_kind update: {e}"))?; + } + + Ok(()) + })(); + + if result.is_ok() { + conn.execute_batch("COMMIT") + .map_err(|e| format!("remove_owner_p_kind commit: {e}"))?; + } else { + let _ = conn.execute_batch("ROLLBACK"); + } + + result +} + +/// Return the `kinds` JSON string for a matching save subscription, or `None` +/// if no subscription exists. +pub fn get_subscription_kinds( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, +) -> Result, String> { + let result = conn + .query_row( + "SELECT kinds FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = ?3 + AND scope_value = ?4", + params![identity_pubkey, relay_url, scope_type, scope_value], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("failed to fetch subscription kinds: {e}"))?; + Ok(result) +} + +// ── Archived events ───────────────────────────────────────────────────────── + +/// Upsert an event row (idempotent on the PK). +/// +/// Does nothing if the event is already archived (same identity/relay/id). +// Args mirror the archived_events columns; a params struct would just rename them. +#[allow(clippy::too_many_arguments)] +pub fn upsert_archived_event( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + event_id: &str, + kind: i64, + pubkey: &str, + created_at: i64, + raw_json: &str, + archived_at: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING", + params![ + identity_pubkey, + relay_url, + event_id, + kind, + pubkey, + created_at, + raw_json, + archived_at + ], + ) + .map_err(|e| format!("failed to upsert archived event: {e}"))?; + Ok(()) +} + +/// Upsert a scope membership row for an event. +/// +/// Idempotent: if the (identity, relay, id, scope_type, scope_value) PK already +/// exists the row is left unchanged. +pub fn upsert_event_scope( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + event_id: &str, + scope_type: &str, + scope_value: &str, + archived_at: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO archived_event_scopes + (identity_pubkey, relay_url, id, scope_type, scope_value, archived_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT (identity_pubkey, relay_url, id, scope_type, scope_value) DO NOTHING", + params![ + identity_pubkey, + relay_url, + event_id, + scope_type, + scope_value, + archived_at + ], + ) + .map_err(|e| format!("failed to upsert event scope: {e}"))?; + Ok(()) +} + +/// Read a paginated page of archived events for a given scope. +/// +/// Returns the `raw_json` of matching events in newest-first order +/// (`ORDER BY created_at DESC, id DESC`). The optional compound cursor +/// `(before_created_at, before_id)` implements keyset pagination: both fields +/// must be `Some` together to activate the cursor (passing one `Some` and one +/// `None` is a logic error at the call site — the store treats mixed `Some`/ +/// `None` as no cursor). The predicate mirrors the sort order exactly: +/// `(created_at < before_created_at) OR (created_at = before_created_at AND +/// id < before_id)`. Pass `None`/`None` to start at the newest end. +/// +/// A scalar `created_at`-only cursor would skip same-second siblings at a page +/// boundary because rows are ordered by `(created_at DESC, id DESC)` — two +/// rows with equal `created_at` on different pages would both be excluded by +/// `created_at < before`. The compound cursor avoids this. +/// +/// An optional `kinds` slice filters by event kind; `None` admits all kinds. +/// +/// Returns at most `limit` rows (caller is responsible for a sane default). +// Query surface: four scope keys + kind filter + compound cursor + limit. +#[allow(clippy::too_many_arguments)] +pub fn read_archived_events( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds: Option<&[i64]>, + before_created_at: Option, + before_id: Option<&str>, + limit: i64, +) -> Result, String> { + // Build clauses and positional params together so slot numbers are always + // contiguous (rusqlite rejects gaps like ?4 then ?6 with no ?5 in between). + // + // Fixed params: identity_pubkey, relay_url, scope_type, scope_value = ?1–?4. + // Optional params appended in declaration order, limit always last. + + let mut next_slot: usize = 5; + let mut extra_clauses = String::new(); + let mut kinds_json: Option = None; + let mut before_at_val: Option = None; + let mut before_id_val: Option = None; + + if let Some(ks) = kinds { + kinds_json = Some(serde_json::to_string(ks).unwrap_or_else(|_| "[]".to_string())); + extra_clauses.push_str(&format!( + " AND ae.kind IN (SELECT value FROM json_each(?{next_slot}))" + )); + next_slot += 1; + } + // Compound cursor: both fields must be Some to activate. The predicate + // mirrors ORDER BY (created_at DESC, id DESC) exactly so no same-second + // sibling is skipped at a page boundary. + if let (Some(bat), Some(bid)) = (before_created_at, before_id) { + before_at_val = Some(bat); + before_id_val = Some(bid.to_owned()); + extra_clauses.push_str(&format!( + " AND (ae.created_at < ?{next_slot} \ + OR (ae.created_at = ?{next_slot} AND ae.id < ?{}))", + next_slot + 1, + )); + next_slot += 2; + } + let limit_slot = next_slot; + + let sql = format!( + "SELECT ae.raw_json \ + FROM archived_events ae \ + INNER JOIN archived_event_scopes aes \ + ON aes.identity_pubkey = ae.identity_pubkey \ + AND aes.relay_url = ae.relay_url \ + AND aes.id = ae.id \ + WHERE ae.identity_pubkey = ?1 \ + AND ae.relay_url = ?2 \ + AND aes.scope_type = ?3 \ + AND aes.scope_value = ?4\ + {extra_clauses}\ + ORDER BY ae.created_at DESC, ae.id DESC \ + LIMIT ?{limit_slot}", + ); + + // Build the param list dynamically to match the generated SQL. + let mut params: Vec> = vec![ + Box::new(identity_pubkey.to_owned()), + Box::new(relay_url.to_owned()), + Box::new(scope_type.to_owned()), + Box::new(scope_value.to_owned()), + ]; + if let Some(kj) = kinds_json { + params.push(Box::new(kj)); + } + if let (Some(bat), Some(bid)) = (before_at_val, before_id_val) { + // Both slots use the same created_at value (the OR predicate references + // it twice); the id slot follows. + params.push(Box::new(bat)); + params.push(Box::new(bid)); + } + params.push(Box::new(limit)); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("prepare read_archived_events: {e}"))?; + + let rows = stmt + .query_map(param_refs.as_slice(), |row| row.get::<_, String>(0)) + .map_err(|e| format!("query read_archived_events: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("read read_archived_events row: {e}")) +} + +/// Upsert a row in `observer_channel_index` for an examined kind 24200 frame. +/// +/// `channel_id` is `Some` for frames with a successfully-decrypted, non-null +/// channelId; `None` for frames that had no channelId or where decryption +/// failed. Null-channel_id rows are excluded from all scoped channel reads +/// (scoped queries filter `channel_id = ?`; NULLs never match), satisfying +/// Will's (a) ruling (2026-07-08), while ensuring the row exists so a re-run +/// is a no-op. +/// +/// Idempotent: if the (identity, relay, id) PK already exists the row is left +/// unchanged (INSERT OR IGNORE). Called both at ingest time (new frames) and +/// from the one-shot backfill for existing archived rows. +pub fn upsert_observer_channel_index( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + event_id: &str, + channel_id: Option<&str>, + created_at: i64, +) -> Result<(), String> { + conn.execute( + "INSERT OR IGNORE INTO observer_channel_index + (identity_pubkey, relay_url, id, channel_id, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![identity_pubkey, relay_url, event_id, channel_id, created_at], + ) + .map_err(|e| format!("failed to upsert observer_channel_index: {e}"))?; + Ok(()) +} + +/// Read all `owner_p` kind 24200 archived event rows (id + raw_json + +/// created_at) that have NOT yet been processed (i.e., have no row in +/// `observer_channel_index`, regardless of what channel_id would be). +/// +/// Used by the one-shot backfill migration to discover which existing rows +/// still need channel attribution attempted. A row is "processed" as soon as +/// we attempt decryption — whether it succeeds (non-null channel_id written) +/// or fails (null channel_id written) — so re-runs skip those rows. +pub fn read_unindexed_observer_rows( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT ae.id, ae.raw_json, ae.created_at + FROM archived_events ae + INNER JOIN archived_event_scopes aes + ON aes.identity_pubkey = ae.identity_pubkey + AND aes.relay_url = ae.relay_url + AND aes.id = ae.id + WHERE ae.identity_pubkey = ?1 + AND ae.relay_url = ?2 + AND ae.kind = 24200 + AND aes.scope_type = 'owner_p' + AND ae.id NOT IN ( + SELECT id FROM observer_channel_index + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + ) + ORDER BY ae.created_at DESC, ae.id DESC", + ) + .map_err(|e| format!("prepare read_unindexed_observer_rows: {e}"))?; + + let rows = stmt + .query_map(params![identity_pubkey, relay_url], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .map_err(|e| format!("query read_unindexed_observer_rows: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("read read_unindexed_observer_rows row: {e}")) +} + +/// Read a paginated page of archived kind 24200 events scoped to one channel, +/// using the `observer_channel_index` as the primary lookup. +/// +/// Returns `raw_json` strings in newest-first order. The compound cursor +/// `(before_created_at, before_id)` works identically to `read_archived_events`. +/// A page shorter than `limit` signals the archive is exhausted for this channel. +#[allow(clippy::too_many_arguments)] +pub fn read_archived_observer_events_for_channel( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + channel_id: &str, + before_created_at: Option, + before_id: Option<&str>, + limit: i64, +) -> Result, String> { + let mut next_slot: usize = 4; + let mut extra_clauses = String::new(); + let mut before_at_val: Option = None; + let mut before_id_val: Option = None; + + if let (Some(bat), Some(bid)) = (before_created_at, before_id) { + before_at_val = Some(bat); + before_id_val = Some(bid.to_owned()); + extra_clauses.push_str(&format!( + " AND (oci.created_at < ?{next_slot} \ + OR (oci.created_at = ?{next_slot} AND oci.id < ?{}))", + next_slot + 1, + )); + next_slot += 2; + } + let limit_slot = next_slot; + + let sql = format!( + "SELECT ae.raw_json \ + FROM observer_channel_index oci \ + INNER JOIN archived_events ae \ + ON ae.identity_pubkey = oci.identity_pubkey \ + AND ae.relay_url = oci.relay_url \ + AND ae.id = oci.id \ + WHERE oci.identity_pubkey = ?1 \ + AND oci.relay_url = ?2 \ + AND oci.channel_id = ?3\ + {extra_clauses}\ + ORDER BY oci.created_at DESC, oci.id DESC \ + LIMIT ?{limit_slot}", + ); + + let mut params_vec: Vec> = vec![ + Box::new(identity_pubkey.to_owned()), + Box::new(relay_url.to_owned()), + Box::new(channel_id.to_owned()), + ]; + if let (Some(bat), Some(bid)) = (before_at_val, before_id_val) { + params_vec.push(Box::new(bat)); + params_vec.push(Box::new(bid)); + } + params_vec.push(Box::new(limit)); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("prepare read_archived_observer_events_for_channel: {e}"))?; + + let rows = stmt + .query_map(param_refs.as_slice(), |row| row.get::<_, String>(0)) + .map_err(|e| format!("query read_archived_observer_events_for_channel: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("read read_archived_observer_events_for_channel row: {e}")) +} + +/// GC: delete orphaned event rows whose last scope row was just removed. +/// +/// Called after any batch deletion of scope rows. Uses a LEFT JOIN so only +/// events with zero remaining scope rows are deleted. +#[allow(dead_code)] // Used by P4 purge commands; not yet wired to a Tauri command. +pub fn gc_orphaned_events( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result { + let affected = conn + .execute( + "DELETE FROM archived_events + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND id NOT IN ( + SELECT id FROM archived_event_scopes + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + )", + params![identity_pubkey, relay_url], + ) + .map_err(|e| format!("failed to gc orphaned events: {e}"))?; + Ok(affected) +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[path = "store_tests.rs"] +mod store_tests; diff --git a/desktop/src-tauri/src/archive/store_tests.rs b/desktop/src-tauri/src/archive/store_tests.rs new file mode 100644 index 0000000000..0a07811e7c --- /dev/null +++ b/desktop/src-tauri/src/archive/store_tests.rs @@ -0,0 +1,852 @@ +//! Unit tests for `archive/store.rs`. +//! +//! Kept in a sibling file so `store.rs` stays under the 1000-line gate; +//! `#[path]`-included from there. + +use super::*; + +fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn +} + +// ── Schema init ────────────────────────────────────────────────────────── + +#[test] +fn test_schema_init_creates_all_tables() { + let conn = in_memory(); + // Verify all three tables exist by inserting a row in each. + conn.execute( + "INSERT INTO save_subscriptions VALUES ('pk','relay','channel_h','abc','[1]',0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO archived_events VALUES ('pk','relay','id1',1,'author',0,'{}',0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO archived_event_scopes VALUES ('pk','relay','id1','channel_h','abc',0)", + [], + ) + .unwrap(); +} + +#[test] +fn test_schema_init_is_idempotent() { + // Running SCHEMA twice must not error. + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); +} + +// ── Save subscriptions ─────────────────────────────────────────────────── + +#[test] +fn test_upsert_save_subscription_inserts_and_updates_kinds() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1]", 1).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].kinds, "[1]"); + + // Update kinds. + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1,6]", 2).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].kinds, "[1,6]"); +} + +#[test] +fn test_list_save_subscriptions_scoped_to_identity_and_relay() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk1", "wss://r1", "channel_h", "a", "[1]", 1).unwrap(); + upsert_save_subscription(&conn, "pk2", "wss://r1", "channel_h", "b", "[1]", 2).unwrap(); + upsert_save_subscription(&conn, "pk1", "wss://r2", "channel_h", "c", "[1]", 3).unwrap(); + + let subs = list_save_subscriptions(&conn, "pk1", "wss://r1").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].scope_value, "a"); +} + +#[test] +fn test_delete_save_subscription_removes_row() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1]", 1).unwrap(); + let deleted = delete_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc").unwrap(); + assert!(deleted); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert!(subs.is_empty()); +} + +#[test] +fn test_delete_save_subscription_returns_false_when_not_found() { + let conn = in_memory(); + let deleted = delete_save_subscription(&conn, "pk", "wss://r", "channel_h", "nope").unwrap(); + assert!(!deleted); +} + +#[test] +fn test_has_save_subscription_true_and_false() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "owner_p", "mypk", "[24200]", 1).unwrap(); + assert!(has_save_subscription(&conn, "pk", "wss://r", "owner_p", "mypk").unwrap()); + assert!(!has_save_subscription(&conn, "pk", "wss://r", "owner_p", "other").unwrap()); +} + +// ── merge_owner_p_kinds ────────────────────────────────────────────────── + +#[test] +fn test_merge_owner_p_kinds_creates_row_when_none_exists() { + let conn = in_memory(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert_eq!(kinds, [24200]); +} + +#[test] +fn test_merge_owner_p_kinds_adds_new_kind_to_existing_row() { + let conn = in_memory(); + // Seed with 24200 first. + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); + // Now merge 44200 in — must produce [24200, 44200]. + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert!(kinds.contains(&24200), "must still contain 24200"); + assert!(kinds.contains(&44200), "must now contain 44200"); + assert_eq!(kinds.len(), 2, "no duplicates"); +} + +#[test] +fn test_merge_owner_p_kinds_idempotent_on_existing_kind() { + let conn = in_memory(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 1).unwrap(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert_eq!(kinds, [44200], "no duplicates after idempotent call"); +} + +/// Two-connection WAL regression test for the BEGIN IMMEDIATE fix. +/// +/// Opens TWO separate connections to the same WAL file (mirroring the +/// real scenario where the observer-archive seed hook and the +/// metric-archive seed hook each open their own connection via +/// `open_archive_db`). Both threads call `merge_owner_p_kinds` +/// concurrently from an empty row. With `BEGIN IMMEDIATE` the losing +/// thread blocks on `busy_timeout` until the winner commits, then +/// reads the committed row and merges its kind in. Both calls must +/// resolve `Ok` and the final row must contain exactly `[24200, 44200]`. +/// +/// A `DEFERRED` transaction would produce `SQLITE_BUSY_SNAPSHOT` on the +/// loser (not retried by busy_timeout), causing one kind to be silently +/// dropped. This test fails in <10 ms if the IMMEDIATE guard is removed. +#[test] +fn test_merge_owner_p_kinds_two_conn_wal_both_kinds_survive() { + use std::sync::{Arc, Barrier}; + use std::thread; + use tempfile::NamedTempFile; + + // A real file DB is required for WAL mode (in-memory dbs don't support + // shared-cache WAL across multiple connections in the same process). + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + // Initialise schema on the file DB via conn-A so both threads see it. + let init_conn = open_archive_db(&db_path).unwrap(); + drop(init_conn); + + // Barrier ensures both threads are inside `merge_owner_p_kinds` before + // either one issues `BEGIN IMMEDIATE`, maximising the race window. + let barrier = Arc::new(Barrier::new(2)); + + let path_a = db_path.clone(); + let path_b = db_path.clone(); + let bar_a = Arc::clone(&barrier); + let bar_b = Arc::clone(&barrier); + + let handle_observer = thread::spawn(move || { + let conn = open_archive_db(&path_a).unwrap(); + bar_a.wait(); // sync: both threads ready + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1) + }); + + let handle_metric = thread::spawn(move || { + let conn = open_archive_db(&path_b).unwrap(); + bar_b.wait(); // sync: both threads ready + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2) + }); + + let res_observer = handle_observer.join().expect("observer thread panicked"); + let res_metric = handle_metric.join().expect("metric thread panicked"); + + assert!( + res_observer.is_ok(), + "observer seed must succeed: {:?}", + res_observer + ); + assert!( + res_metric.is_ok(), + "metric seed must succeed: {:?}", + res_metric + ); + + // Verify the final row contains both kinds. + let verify_conn = open_archive_db(&db_path).unwrap(); + let subs = list_save_subscriptions(&verify_conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1, "exactly one owner_p row"); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert!( + kinds.contains(&24200), + "observer kind 24200 must survive concurrent metric seed; got {:?}", + kinds + ); + assert!( + kinds.contains(&44200), + "metric kind 44200 must be present after concurrent seed; got {:?}", + kinds + ); + assert_eq!( + kinds.len(), + 2, + "exactly two kinds, no duplicates; got {:?}", + kinds + ); +} + +// ── remove_owner_p_kind ────────────────────────────────────────────────── + +#[test] +fn test_remove_owner_p_kind_removes_one_kind_leaving_other() { + let conn = in_memory(); + // Seed both kinds. + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2).unwrap(); + + // Remove 44200 — 24200 must survive. + remove_owner_p_kind(&conn, "pk", "wss://r", "mypk", 44200).unwrap(); + + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1, "row must still exist"); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert_eq!( + kinds, + [24200], + "only 24200 remains after removing 44200; got {kinds:?}" + ); +} + +#[test] +fn test_remove_owner_p_kind_deletes_row_when_last_kind_removed() { + let conn = in_memory(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 1).unwrap(); + + // Remove the only kind — row must be deleted. + remove_owner_p_kind(&conn, "pk", "wss://r", "mypk", 44200).unwrap(); + + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert!( + subs.is_empty(), + "row must be deleted when last kind removed" + ); +} + +#[test] +fn test_remove_owner_p_kind_noop_when_row_absent() { + let conn = in_memory(); + // No row exists — must succeed silently. + remove_owner_p_kind(&conn, "pk", "wss://r", "mypk", 24200).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert!(subs.is_empty()); +} + +#[test] +fn test_remove_owner_p_kind_noop_when_kind_absent() { + let conn = in_memory(); + // Row exists with only 24200 — removing 44200 must leave row unchanged. + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); + remove_owner_p_kind(&conn, "pk", "wss://r", "mypk", 44200).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert_eq!(kinds, [24200]); +} + +// ── Archived events ────────────────────────────────────────────────────── + +#[test] +fn test_upsert_archived_event_is_idempotent() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + // Second call must not error or duplicate. + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 201).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); +} + +// ── Many-to-many scope rows ────────────────────────────────────────────── + +#[test] +fn test_one_event_gets_multiple_scope_rows() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "chan1", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "referenced_e", "evref", 200).unwrap(); + // Idempotent second insert. + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "chan1", 201).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes WHERE id = 'id1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 2); +} + +// ── GC ─────────────────────────────────────────────────────────────────── + +#[test] +fn test_gc_removes_event_when_last_scope_deleted() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "c1", 200).unwrap(); + // Delete the only scope row manually. + conn.execute("DELETE FROM archived_event_scopes WHERE id = 'id1'", []) + .unwrap(); + let removed = gc_orphaned_events(&conn, "pk", "wss://r").unwrap(); + assert_eq!(removed, 1); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); +} + +#[test] +fn test_gc_leaves_event_with_remaining_scope() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "c1", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "referenced_e", "ref", 200).unwrap(); + // Delete only one scope row. + conn.execute( + "DELETE FROM archived_event_scopes WHERE scope_type = 'referenced_e'", + [], + ) + .unwrap(); + let removed = gc_orphaned_events(&conn, "pk", "wss://r").unwrap(); + assert_eq!(removed, 0); +} + +// ── read_archived_events ───────────────────────────────────────────────── + +fn seed_events(conn: &Connection) { + // Three events in scope "channel_h/chan1" for identity "pk"/"wss://r". + // created_at: 300 (newest), 200, 100 (oldest). + for (id, kind, created_at, raw) in &[ + ("e1", 9i64, 300i64, r#"{"id":"e1","created_at":300}"#), + ("e2", 9i64, 200i64, r#"{"id":"e2","created_at":200}"#), + ("e3", 42i64, 100i64, r#"{"id":"e3","created_at":100}"#), + ] { + upsert_archived_event( + conn, + "pk", + "wss://r", + id, + *kind, + "author", + *created_at, + raw, + 999, + ) + .unwrap(); + upsert_event_scope(conn, "pk", "wss://r", id, "channel_h", "chan1", 999).unwrap(); + } + // One event in a different scope — must never appear in chan1 results. + upsert_archived_event( + conn, + "pk", + "wss://r", + "e4", + 9, + "author", + 250, + r#"{"id":"e4"}"#, + 999, + ) + .unwrap(); + upsert_event_scope(conn, "pk", "wss://r", "e4", "channel_h", "chan2", 999).unwrap(); +} + +#[test] +fn test_read_archived_events_returns_newest_first() { + let conn = in_memory(); + seed_events(&conn); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 3); + // Newest first: e1 (300), e2 (200), e3 (100). + let ids: Vec<&str> = rows + .iter() + .map(|r| { + if r.contains("\"e1\"") { + "e1" + } else if r.contains("\"e2\"") { + "e2" + } else { + "e3" + } + }) + .collect(); + assert_eq!(ids, ["e1", "e2", "e3"]); +} + +#[test] +fn test_read_archived_events_keyset_cursor_excludes_at_boundary() { + let conn = in_memory(); + seed_events(&conn); + // Compound cursor at e1 (created_at=300, id="e1"): excludes e1 itself, + // returns e2 and e3. + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(300), + Some("e1"), + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| !r.contains("\"e1\""))); +} + +#[test] +fn test_read_archived_events_keyset_cursor_advances_correctly() { + let conn = in_memory(); + seed_events(&conn); + // Page 1: before=None/None, limit=2 → e1, e2. + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 2, + ) + .unwrap(); + assert_eq!(page1.len(), 2); + // Page 2: compound cursor at e2 (created_at=200, id="e2") → e3 only. + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(200), + Some("e2"), + 2, + ) + .unwrap(); + assert_eq!(page2.len(), 1); + assert!(page2[0].contains("\"e3\"")); + // No overlap between pages. + assert!(page1.iter().all(|r| !r.contains("\"e3\""))); +} + +#[test] +fn test_read_archived_events_kind_filter() { + let conn = in_memory(); + seed_events(&conn); + // Only kind 9 (e1 and e2); e3 is kind 42. + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + Some(&[9]), + None, + None, + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| !r.contains("\"e3\""))); +} + +#[test] +fn test_read_archived_events_scope_isolation() { + let conn = in_memory(); + seed_events(&conn); + // chan2 has only e4; chan1 results must not include e4. + let chan1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(chan1.iter().all(|r| !r.contains("\"e4\""))); + + let chan2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan2", + None, + None, + None, + 10, + ) + .unwrap(); + assert_eq!(chan2.len(), 1); + assert!(chan2[0].contains("\"e4\"")); +} + +#[test] +fn test_read_archived_events_identity_isolation() { + let conn = in_memory(); + seed_events(&conn); + // Different identity — must see no rows. + let rows = read_archived_events( + &conn, + "pk2", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); +} + +#[test] +fn test_read_archived_events_relay_isolation() { + let conn = in_memory(); + seed_events(&conn); + // Different relay — must see no rows. + let rows = read_archived_events( + &conn, + "pk", + "wss://other", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); +} + +#[test] +fn test_read_archived_events_empty_result() { + let conn = in_memory(); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "nope", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); +} + +#[test] +fn test_read_archived_events_limit_respected() { + let conn = in_memory(); + seed_events(&conn); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 1, + ) + .unwrap(); + assert_eq!(rows.len(), 1); + // Must be the newest (e1, created_at=300). + assert!(rows[0].contains("\"e1\"")); +} + +#[test] +fn test_read_archived_events_no_duplicates_across_pages() { + let conn = in_memory(); + seed_events(&conn); + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 2, + ) + .unwrap(); + // Compound cursor at e2 (the oldest in page1: created_at=200, id="e2"). + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(200), + Some("e2"), + 2, + ) + .unwrap(); + // All event ids across both pages are unique. + let all: Vec<_> = page1.iter().chain(page2.iter()).collect(); + assert_eq!(all.len(), 3); // 2 + 1 = 3 total, no duplication. +} + +/// Regression for the scalar-cursor same-second skip defect (Thufir IMPORTANT). +/// +/// The writer stores `created_at` in whole seconds, so two events can share +/// the same timestamp. The sort order is `(created_at DESC, id DESC)`, so +/// a page split exactly at a same-second boundary leaves one sibling on each +/// side. With only `created_at < before` the second-page sibling would be +/// permanently excluded. The compound `(created_at < ?) OR (created_at = ? +/// AND id < ?)` predicate mirrors the sort key exactly and avoids the skip. +#[test] +fn test_read_archived_events_same_second_cursor_no_skip() { + let conn = in_memory(); + // Two events share created_at=1000. Sort order: "z" (id "z") > "a" (id "a"), + // so ORDER BY created_at DESC, id DESC yields: ("z", 1000) first, ("a", 1000) second. + // A third event has created_at=500. + for (id, kind, created_at, raw) in &[ + ("z", 9i64, 1000i64, r#"{"id":"z","created_at":1000}"#), + ("a", 9i64, 1000i64, r#"{"id":"a","created_at":1000}"#), + ("old", 9i64, 500i64, r#"{"id":"old","created_at":500}"#), + ] { + upsert_archived_event( + &conn, + "pk", + "wss://r", + id, + *kind, + "author", + *created_at, + raw, + 999, + ) + .unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", id, "channel_h", "same_sec", 999).unwrap(); + } + + // Page 1: limit=1 → should return ("z", 1000) only (newest by compound sort). + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "same_sec", + None, + None, + None, + 1, + ) + .unwrap(); + assert_eq!(page1.len(), 1); + assert!(page1[0].contains("\"z\""), "page1 must be the 'z' row"); + + // Page 2: compound cursor at ("z", 1000). + // With a scalar cursor (created_at < 1000), row "a" would be SKIPPED. + // With the compound cursor, "a" must appear on page 2. + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "same_sec", + None, + Some(1000), + Some("z"), + 2, + ) + .unwrap(); + // Must contain "a" (same-second sibling) and "old" (strictly older). + assert_eq!(page2.len(), 2, "page2 must return both remaining rows"); + assert!( + page2.iter().any(|r| r.contains("\"a\"")), + "same-second sibling 'a' must not be skipped" + ); + assert!( + page2.iter().any(|r| r.contains("\"old\"")), + "'old' row must appear on page2" + ); + // No overlap with page1. + assert!(page2.iter().all(|r| !r.contains("\"z\""))); +} + +// ── observer_channel_index ─────────────────────────────────────────────────── + +#[test] +fn test_upsert_observer_channel_index_non_null_is_idempotent() { + let conn = in_memory(); + let pk = "owner"; + let relay = "wss://r"; + + upsert_observer_channel_index(&conn, pk, relay, "ev1", Some("ch-abc"), 1000).unwrap(); + // Second call with same PK → INSERT OR IGNORE, no error, row unchanged. + upsert_observer_channel_index(&conn, pk, relay, "ev1", Some("ch-abc"), 2000).unwrap(); + + // Only one row should exist and created_at stays at 1000 (first write wins). + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM observer_channel_index WHERE id = 'ev1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "INSERT OR IGNORE must not duplicate rows"); + + let at: i64 = conn + .query_row( + "SELECT created_at FROM observer_channel_index WHERE id = 'ev1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(at, 1000, "first write's created_at must be preserved"); +} + +#[test] +fn test_upsert_observer_channel_index_null_channel_id_is_idempotent() { + let conn = in_memory(); + let pk = "owner"; + let relay = "wss://r"; + + // Write a NULL channel_id status row (unscoped / decrypt-failed frame). + upsert_observer_channel_index(&conn, pk, relay, "ev-null", None, 500).unwrap(); + // Re-run → no-op. + upsert_observer_channel_index(&conn, pk, relay, "ev-null", None, 600).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM observer_channel_index WHERE id = 'ev-null'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count, 1, + "null-channel_id row must not be duplicated on re-run" + ); + + let channel_id: Option = conn + .query_row( + "SELECT channel_id FROM observer_channel_index WHERE id = 'ev-null'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + channel_id.is_none(), + "channel_id must be NULL for unscoped frames" + ); +} + +#[test] +fn test_read_archived_observer_excludes_null_channel_id_rows() { + // Scoped reads filter channel_id = '?'; NULL rows must never appear. + let conn = in_memory(); + let pk = "owner"; + let relay = "wss://r"; + + // Seed an archived event row. + upsert_archived_event(&conn, pk, relay, "ev-null", 24200, "agent", 1000, "{}", 0).unwrap(); + upsert_event_scope(&conn, pk, relay, "ev-null", "owner_p", pk, 0).unwrap(); + + // Index it with a NULL channel_id. + upsert_observer_channel_index(&conn, pk, relay, "ev-null", None, 1000).unwrap(); + + // Scoped read for ANY channel must return nothing (NULL != 'some-channel'). + let rows = + read_archived_observer_events_for_channel(&conn, pk, relay, "some-channel", None, None, 50) + .unwrap(); + assert!( + rows.is_empty(), + "scoped read must not return null-channel_id rows" + ); +} + +#[test] +fn test_read_unindexed_observer_rows_excludes_processed_rows() { + // After a NULL channel_id row is written, the event must no longer appear + // in read_unindexed_observer_rows (it is "processed"). + let conn = in_memory(); + let pk = "owner"; + let relay = "wss://r"; + + // Seed an archived observer event with owner_p scope. + upsert_archived_event(&conn, pk, relay, "ev-old", 24200, "agent", 1000, "{}", 0).unwrap(); + upsert_event_scope(&conn, pk, relay, "ev-old", "owner_p", pk, 0).unwrap(); + + // Before indexing: ev-old must appear in unindexed rows. + let unindexed = read_unindexed_observer_rows(&conn, pk, relay).unwrap(); + assert!( + unindexed.iter().any(|(id, _, _)| id == "ev-old"), + "ev-old must appear in unindexed rows before indexing" + ); + + // Index it with NULL channel_id (decrypt-failed / unscoped). + upsert_observer_channel_index(&conn, pk, relay, "ev-old", None, 1000).unwrap(); + + // After indexing: ev-old must NOT appear in unindexed rows (re-run is a no-op). + let unindexed2 = read_unindexed_observer_rows(&conn, pk, relay).unwrap(); + assert!( + !unindexed2.iter().any(|(id, _, _)| id == "ev-old"), + "ev-old must be excluded from unindexed rows after null-channel_id indexing" + ); +} diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 1667b9412b..f6141a53eb 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -1,37 +1,62 @@ +use serde::Serialize; use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ config_bridge::{ + read_goose_file_config, reader::read_config_surface, types::{ AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin, NormalizedField, RuntimeConfigSurface, SessionConfigCache, }, }, - current_instance_id, effective_agent_command, known_acp_runtime, load_managed_agents, - load_personas, resolve_effective_prompt_model_provider, save_managed_agents, - sync_managed_agent_processes, KnownAcpRuntime, ManagedAgentRecord, PersonaRecord, + current_instance_id, known_acp_runtime, load_managed_agents, load_personas, + resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, + AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, }, }; -/// Resolve the config surface with persona values applied. +/// Subset of the goose file config exposed to the frontend for gate evaluation. +/// +/// Only the fields the dialog gate needs — not the full `RuntimeConfigSurface`. +/// The gate uses this to know which requirements are already satisfied in the +/// harness config file, so it can show "Set in goose config" rather than +/// surfacing a false missing-key marker. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeFileConfigSubset { + /// Provider set in the harness config file, if any. + pub provider: Option, + /// Model set in the harness config file, if any. + pub model: Option, + /// Flat credential env keys found in the harness config file's `extra` map + /// (e.g. `DATABRICKS_HOST`). Only non-empty values are included. + pub satisfied_env_keys: Vec, +} + +/// Resolve the config surface with persona and global default values applied. /// /// The pipeline: resolve the linked persona's prompt/model/provider, inject /// each into the record only where the record lacks its own value, let /// `read_config_surface` tag those injected fields `BuzzExplicit`, then re-tag /// exactly the injected fields to `PersonaDefault`. /// +/// Global defaults fill in when neither the record nor the linked persona +/// provides a value. They are re-tagged to `GlobalDefault` so the UI can +/// display "inherited from global defaults". +/// /// The re-tag is triple-gated — a field is re-tagged only when (a) the record /// did not already have it (`!had_*`), (b) the surface produced the field, and /// (c) the reader tagged it `BuzzExplicit`. A value the user set explicitly in /// Buzz keeps `had_* == true` and is never re-tagged. fn resolve_config_surface( mut record: ManagedAgentRecord, - personas: &[PersonaRecord], + personas: &[AgentDefinition], runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, + global: &GlobalAgentConfig, ) -> RuntimeConfigSurface { let had_prompt = record.system_prompt.is_some() || record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT"); @@ -45,6 +70,7 @@ fn resolve_config_surface( personas, record.system_prompt.clone(), record.model.clone(), + record.provider.clone(), ); // Build the baseline the reader overrides a live model against, paired with @@ -67,9 +93,21 @@ fn resolve_config_surface( None } } else { + // Prefer persona as baseline, fall back to global when persona has none + // and the model was overridden mid-session (global-default agent). persona_model .clone() .map(|m| (m, ConfigOrigin::PersonaDefault)) + .or_else(|| { + if model_overridden { + global + .model + .clone() + .map(|m| (m, ConfigOrigin::GlobalDefault)) + } else { + None + } + }) }; // Inject resolved persona values into the record where absent. @@ -89,6 +127,24 @@ fn resolve_config_surface( } } + // Inject global defaults where neither the record nor the persona had a value. + // Track injection so we can re-tag to GlobalDefault after the reader. + let inject_global_model = !had_model && record.model.is_none(); + let inject_global_provider = !had_provider + && !provider_env_key.is_empty() + && !record.env_vars.contains_key(provider_env_key); + + if inject_global_model { + record.model = global.model.clone(); + } + if inject_global_provider { + if let Some(ref gprov) = global.provider { + record + .env_vars + .insert(provider_env_key.to_string(), gprov.clone()); + } + } + let mut surface = read_config_surface( &record, runtime_meta, @@ -100,13 +156,21 @@ fn resolve_config_surface( if !had_prompt { retag_persona_default(&mut surface.normalized.system_prompt); } - if !had_model { + if !had_model && !inject_global_model { retag_persona_default(&mut surface.normalized.model); } - if !had_provider && !provider_env_key.is_empty() { + if !had_provider && !provider_env_key.is_empty() && !inject_global_provider { retag_persona_default(&mut surface.normalized.provider); } + // Re-tag global-sourced fields from BuzzExplicit to GlobalDefault. + if inject_global_model { + retag_global_default(&mut surface.normalized.model); + } + if inject_global_provider { + retag_global_default(&mut surface.normalized.provider); + } + // Re-tag persona-snapshotted model from BuzzExplicit to PersonaDefault. // Persona-created agents have record.model set at create time from the // persona snapshot — had_model is true, but the model came from the persona, @@ -136,6 +200,136 @@ fn retag_persona_default(field: &mut Option) { } } +/// Get the file-layer config for a runtime — used by the Create/Edit/Persona +/// dialogs to know which requirements are already satisfied in the harness +/// config file (e.g. `~/.config/goose/config.yaml`), so they can show +/// "Set in goose config" instead of surfacing a false required-field marker. +/// +/// Returns `null` when the runtime has no config file or it cannot be parsed. +/// Currently only "goose" is supported; other runtimes return `null`. +#[tauri::command] +pub async fn get_runtime_file_config( + runtime_id: String, +) -> Result, String> { + tokio::task::spawn_blocking(move || match runtime_id.as_str() { + "goose" => { + let cfg = read_goose_file_config()?; + let satisfied_env_keys = cfg + .extra + .into_iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, _)| k) + .collect(); + Some(RuntimeFileConfigSubset { + provider: cfg.provider, + model: cfg.model, + satisfied_env_keys, + }) + } + _ => None, + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) +} + +/// Return the key names of all non-empty baked build env vars. +/// +/// Internal (Block) builds bake provider credentials and other env pairs into +/// the binary at compile time via `BUZZ_BUILD_AGENT_ENV`. The backend readiness +/// gate already treats these keys as satisfying their requirements (Layer 1 of +/// `resolve_effective_agent_env`). This command exposes the *key names only* — +/// never the values — so the frontend dialogs can apply the same logic and avoid +/// surfacing a spurious "Required" badge for keys that are covered by the baked +/// env. +/// +/// OSS builds have no baked env, so this returns an empty list — OSS behavior +/// is unchanged. +#[tauri::command] +pub fn get_baked_build_env_keys() -> Vec { + crate::managed_agents::baked_build_env() + .into_iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, _)| k) + .collect() +} + +/// A single baked build env entry returned to the frontend. +/// +/// Values are masked in Rust so unmasked secret values never cross the +/// Tauri IPC boundary. The `masked` flag lets the frontend style masked +/// rows distinctly. +#[derive(Debug, Clone, serde::Serialize)] +pub struct BakedEnvEntry { + pub key: String, + /// The display value — real value for non-secret keys, `••••••` for + /// secret keys whose names match the secret heuristic. + pub value: String, + /// `true` when the value was replaced by the mask placeholder. + pub masked: bool, +} + +/// Returns `true` when a baked-env key is safe to display unmasked in the UI. +/// +/// This uses an explicit allowlist of keys that are known safe (non-secret). +/// Any key NOT in this set is masked — default-deny for a security surface. +/// +/// Allowlist (case-insensitive): +/// - `BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL` — agent runtime selection +/// - `BUZZ_AGENT_THINKING_EFFORT` — non-secret enum (none/minimal/low/medium/high/xhigh/max) +/// - `DATABRICKS_HOST`, `DATABRICKS_MODEL` — Block non-secret defaults +fn is_safe_to_reveal(key: &str) -> bool { + const SAFE_KEYS: &[&str] = &[ + "BUZZ_AGENT_PROVIDER", + "BUZZ_AGENT_MODEL", + "BUZZ_AGENT_THINKING_EFFORT", + "DATABRICKS_HOST", + "DATABRICKS_MODEL", + ]; + let upper = key.to_ascii_uppercase(); + SAFE_KEYS.iter().any(|safe| upper == *safe) +} + +/// Expose the baked build env to the frontend with values shown, but any +/// key not in the safe-to-reveal allowlist has its value replaced by `••••••`. +/// +/// Provider and model arrive as `BUZZ_AGENT_PROVIDER` / `BUZZ_AGENT_MODEL` +/// keys in `baked_build_env()` and are included in the returned list like any +/// other key. Empty-value keys are filtered out (same as +/// `get_baked_build_env_keys`). +/// +/// OSS builds return an empty list — the baked-env section is hidden entirely +/// in OSS installations. +#[tauri::command] +pub fn get_baked_build_env() -> Vec { + crate::managed_agents::baked_build_env() + .into_iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(key, value)| { + let masked = !is_safe_to_reveal(&key); + let display_value = if masked { + "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}".to_string() + } else { + value + }; + BakedEnvEntry { + key, + value: display_value, + masked, + } + }) + .collect() +} + +/// Re-tag a field's origin from `BuzzExplicit` to `GlobalDefault`, leaving any +/// other origin untouched. No-op when the field is absent. +fn retag_global_default(field: &mut Option) { + if let Some(field) = field { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::GlobalDefault; + } + } +} + /// Get the full config surface for a managed agent. /// /// Returns normalized + advanced config from all available tiers. @@ -172,19 +366,17 @@ pub async fn get_agent_config_surface( }; let personas = load_personas(&app).unwrap_or_default(); - let effective_cmd = effective_agent_command( - record.persona_id.as_deref(), - &personas, - record.agent_command_override.as_deref(), - ); + let effective_cmd = crate::managed_agents::record_agent_command(&record, &personas); let runtime_meta = known_acp_runtime(&effective_cmd); let session_cache = state.get_session_cache(&pubkey); + let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); Ok(resolve_config_surface( record, &personas, runtime_meta, session_cache.as_ref(), + &global, )) } @@ -390,6 +582,7 @@ mod tests { mcp_hooks: false, underlying_cli: None, cli_install_commands: &[], + cli_install_commands_windows: &[], adapter_install_commands: &[], install_instructions_url: "", cli_install_hint: "", @@ -407,6 +600,8 @@ mod tests { max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, } } @@ -429,13 +624,14 @@ mod tests { parallelism: 1, system_prompt: None, model: None, - mcp_toolsets: None, env_vars: BTreeMap::new(), start_on_app_launch: false, + auto_restart_on_config_change: true, runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, provider_binary_path: None, + team_id: None, persona_team_dir: None, persona_name_in_team: None, created_at: "".to_string(), @@ -444,8 +640,20 @@ mod tests { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: RespondTo::OwnerOnly, respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, relay_mesh: None, agent_command_override: None, persona_source_version: None, @@ -453,8 +661,8 @@ mod tests { } } - fn persona_with_model(model: &str) -> PersonaRecord { - PersonaRecord { + fn persona_with_model(model: &str) -> AgentDefinition { + AgentDefinition { id: "persona-1".to_string(), display_name: "Persona".to_string(), avatar_url: None, @@ -468,6 +676,9 @@ mod tests { source_team: None, source_team_persona_slug: None, env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), } @@ -496,7 +707,13 @@ mod tests { record.model = Some("explicit-model".to_string()); let personas = vec![persona_with_model("persona-model")]; - let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None); + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &Default::default(), + ); let model = surface.normalized.model.as_ref().expect("model resolved"); assert_eq!(model.value.as_deref(), Some("explicit-model")); @@ -514,11 +731,16 @@ mod tests { let mut record = agent_record(); record.persona_id = None; record.model = Some("model-x".to_string()); - let personas: Vec = vec![]; + let personas: Vec = vec![]; let cache = session_cache("model-y", false); - let surface = - resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ); let model = surface.normalized.model.expect("model resolved"); assert_eq!(model.value.as_deref(), Some("model-x")); @@ -537,11 +759,16 @@ mod tests { let mut record = agent_record(); record.persona_id = None; record.model = Some("model-x".to_string()); - let personas: Vec = vec![]; + let personas: Vec = vec![]; let cache = session_cache("model-y", true); - let surface = - resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ); let model = surface.normalized.model.expect("model resolved"); assert_eq!(model.value.as_deref(), Some("model-y")); @@ -559,11 +786,16 @@ mod tests { let mut record = agent_record(); record.persona_id = None; record.model = Some("model-x".to_string()); - let personas: Vec = vec![]; + let personas: Vec = vec![]; let cache = session_cache("model-x", true); - let surface = - resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ); let model = surface.normalized.model.expect("model resolved"); assert_eq!(model.value.as_deref(), Some("model-x")); @@ -581,8 +813,13 @@ mod tests { let personas = vec![persona_with_model("persona-model")]; let cache = session_cache("model-y", true); - let surface = - resolve_config_surface(record, &personas, Some(goose_runtime()), Some(&cache)); + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ); let model = surface.normalized.model.expect("model resolved"); assert_eq!(model.value.as_deref(), Some("model-y")); @@ -590,4 +827,193 @@ mod tests { assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); } + + /// Fix 2 regression: a global-default-only agent (no record model, no + /// persona model, but global has a model) that live-switches mid-session + /// must render the global model as the secondary tagged `GlobalDefault`. + /// Before the fix, `baseline` was `None` in the `!had_model` arm when + /// persona has no model, so `read_config_surface` had no secondary to + /// surface. Fails against pre-fix code where the baseline arm returned + /// `None` when `!had_model && persona_model.is_none() && model_overridden`. + #[test] + fn global_default_live_switch_renders_global_model_as_secondary_global_default() { + // Record has no model, no persona, global provides the model. + let mut record = agent_record(); + record.persona_id = None; + // record.model = None (set by agent_record()) + let personas: Vec = vec![]; + let cache = session_cache("model-y", true); + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + ..Default::default() + }; + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &global, + ); + let model = surface.normalized.model.expect("model resolved"); + + // Live model wins as primary. + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + // Global model surfaces as secondary, tagged GlobalDefault. + assert_eq!( + model.overridden_value.as_deref(), + Some("global-model"), + "global model must be the override baseline secondary" + ); + assert_eq!( + model.overridden_origin, + Some(ConfigOrigin::GlobalDefault), + "override baseline origin must be GlobalDefault, not PersonaDefault or BuzzExplicit" + ); + } + + // ── get_baked_build_env / is_secret_key tests ────────────────────────── + + /// Build a `BakedEnvEntry` vec from a synthetic map, mirroring what + /// `get_baked_build_env()` does. Used to test masking without relying on + /// compile-time `option_env!` vars (OSS builds have empty `baked_build_env`). + fn baked_env_from_map(map: &[(&str, &str)]) -> Vec { + map.iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, v)| { + let masked = !super::is_safe_to_reveal(k); + BakedEnvEntry { + key: k.to_string(), + value: if masked { + "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}".to_string() + } else { + v.to_string() + }, + masked, + } + }) + .collect() + } + + #[test] + fn baked_env_non_secret_key_shows_real_value() { + let entries = baked_env_from_map(&[("BUZZ_AGENT_PROVIDER", "databricks_v2")]); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].key, "BUZZ_AGENT_PROVIDER"); + assert_eq!(entries[0].value, "databricks_v2"); + assert!(!entries[0].masked); + } + + #[test] + fn baked_env_api_key_is_masked() { + let entries = baked_env_from_map(&[("ANTHROPIC_API_KEY", "sk-secret")]); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].value, "••••••"); + assert!(entries[0].masked); + } + + #[test] + fn baked_env_token_key_is_masked() { + let entries = baked_env_from_map(&[("GITHUB_TOKEN", "ghp_secret")]); + assert_eq!(entries.len(), 1); + assert!(entries[0].masked); + } + + #[test] + fn baked_env_secret_key_is_masked() { + let entries = baked_env_from_map(&[("MY_DB_SECRET", "s3cr3t")]); + assert_eq!(entries.len(), 1); + assert!(entries[0].masked); + } + + #[test] + fn baked_env_password_key_is_masked() { + let entries = baked_env_from_map(&[("DB_PASSWORD", "hunter2")]); + assert_eq!(entries.len(), 1); + assert!(entries[0].masked); + } + + #[test] + fn baked_env_empty_value_filtered_out() { + let entries = baked_env_from_map(&[("BUZZ_AGENT_PROVIDER", "")]); + assert!(entries.is_empty()); + } + + #[test] + fn baked_env_mixed_keys_correct_masking() { + let entries = baked_env_from_map(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ("BUZZ_AGENT_MODEL", "goose-claude-opus-4-8"), + ("DATABRICKS_HOST", "https://example.com"), + ("DATABRICKS_TOKEN", "dapi-secret"), + ]); + assert_eq!(entries.len(), 4); + + let provider = entries + .iter() + .find(|e| e.key == "BUZZ_AGENT_PROVIDER") + .unwrap(); + assert_eq!(provider.value, "databricks_v2"); + assert!(!provider.masked); + + let model = entries + .iter() + .find(|e| e.key == "BUZZ_AGENT_MODEL") + .unwrap(); + assert_eq!(model.value, "goose-claude-opus-4-8"); + assert!(!model.masked); + + let host = entries.iter().find(|e| e.key == "DATABRICKS_HOST").unwrap(); + assert_eq!(host.value, "https://example.com"); + assert!(!host.masked); + + let token = entries + .iter() + .find(|e| e.key == "DATABRICKS_TOKEN") + .unwrap(); + assert_eq!(token.value, "••••••"); + assert!(token.masked); + } + + #[test] + fn baked_env_thinking_effort_is_unmasked() { + // BUZZ_AGENT_THINKING_EFFORT is a non-secret enum — must not be masked. + let entries = baked_env_from_map(&[("BUZZ_AGENT_THINKING_EFFORT", "medium")]); + assert_eq!(entries.len(), 1); + let effort = entries + .iter() + .find(|e| e.key == "BUZZ_AGENT_THINKING_EFFORT") + .unwrap(); + assert_eq!(effort.value, "medium"); + assert!(!effort.masked); + } + + #[test] + fn baked_env_allowlist_is_case_insensitive() { + // Known-safe keys — case-insensitive match must allow them. + assert!(super::is_safe_to_reveal("buzz_agent_provider")); + assert!(super::is_safe_to_reveal("BUZZ_AGENT_PROVIDER")); + assert!(super::is_safe_to_reveal("buzz_agent_model")); + assert!(super::is_safe_to_reveal("BUZZ_AGENT_MODEL")); + assert!(super::is_safe_to_reveal("buzz_agent_thinking_effort")); + assert!(super::is_safe_to_reveal("BUZZ_AGENT_THINKING_EFFORT")); + assert!(super::is_safe_to_reveal("databricks_host")); + assert!(super::is_safe_to_reveal("DATABRICKS_HOST")); + assert!(super::is_safe_to_reveal("databricks_model")); + assert!(super::is_safe_to_reveal("DATABRICKS_MODEL")); + // Keys NOT in the allowlist — masked regardless of naming pattern. + assert!(!super::is_safe_to_reveal("my_api_key")); + assert!(!super::is_safe_to_reveal("GITHUB_TOKEN")); + assert!(!super::is_safe_to_reveal("DB_SECRET")); + assert!(!super::is_safe_to_reveal("DB_PASSWORD")); + // Bare names that old heuristic (contains("_TOKEN") etc.) would have missed. + assert!(!super::is_safe_to_reveal("APIKEY")); + assert!(!super::is_safe_to_reveal("TOKEN")); + assert!(!super::is_safe_to_reveal("SECRET")); + assert!(!super::is_safe_to_reveal("PASSWORD")); + assert!(!super::is_safe_to_reveal("PRIVATE_KEY")); + // Unknown key → masked by default. + assert!(!super::is_safe_to_reveal("SOME_UNKNOWN_KEY")); + } } diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 35462cffc1..bf4ba73152 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -4,9 +4,9 @@ use tauri::State; use crate::{ app_state::AppState, managed_agents::{ - command_availability, AcpRuntimeCatalogEntry, DiscoverManagedAgentPrereqsRequest, - InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo, - DEFAULT_ACP_COMMAND, + command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, + DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, InstallStepResult, + ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, }, nostr_convert, relay::query_relay, @@ -19,22 +19,104 @@ fn active_installs() -> &'static std::sync::Mutex Vec { - crate::managed_agents::clear_resolve_cache(); - crate::managed_agents::discover_acp_runtimes() +/// Returns the adapter install commands that `install_acp_runtime_blocking` would +/// run for `runtime_id` given a resolved adapter binary at `adapter_path` (or +/// `None` if none was found). +/// +/// Returns `None` when no install is needed (adapter is present and current). +/// Returns `Some(cmds)` when the adapter is missing or (for codex) outdated. +/// +/// For the codex **outdated** case the returned sequence is a two-step +/// reinstall: first uninstall the old `@zed-industries/codex-acp` package +/// (idempotent — exit 0 when absent), then install the new +/// `@agentclientprotocol/codex-acp`. This is required because both packages +/// install a global binary named `codex-acp`, and npm ≥7 refuses to overwrite +/// a bin file owned by a different package with `EEXIST`. +/// +/// For the **missing** case the catalog's `adapter_install_commands` are used +/// as-is (no prior package to remove). +/// +/// This is a pure planning function: it never spawns a process. Tests use it to +/// assert the correct install command is selected without touching real npm. +pub(crate) fn plan_adapter_install<'c>( + runtime_id: &str, + adapter_path: Option<&std::path::Path>, + adapter_install_commands: &'c [&'c str], +) -> Option> { + match adapter_path { + // Adapter present and current — no install needed. + Some(_) if runtime_id != "codex" => None, + Some(path) if !crate::managed_agents::codex_adapter_is_outdated(path) => None, + // Codex adapter is outdated: uninstall the old package first so npm + // doesn't hit EEXIST on the shared `codex-acp` bin-link, then install. + Some(_) => Some(vec![ + "npm uninstall -g @zed-industries/codex-acp", + "npm install -g @agentclientprotocol/codex-acp", + ]), + // Adapter missing: use the catalog's install commands directly. + None => Some(adapter_install_commands.to_vec()), + } } #[tauri::command] -pub async fn install_acp_runtime(runtime_id: String) -> Result { - tokio::task::spawn_blocking(move || install_acp_runtime_blocking(&runtime_id)) - .await - .map_err(|e| format!("install task panicked: {e}"))? +pub async fn discover_acp_providers() -> Result, String> { + tokio::task::spawn_blocking(|| { + crate::managed_agents::clear_resolve_cache(); + crate::managed_agents::refresh_login_shell_path(); + crate::managed_agents::discover_acp_runtimes() + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) +} + +#[tauri::command] +pub async fn install_acp_runtime( + runtime_id: String, + app: tauri::AppHandle, +) -> Result { + // ── Phase 1: blocking install ──────────────────────────────────────────── + // + // Run the npm install steps synchronously in spawn_blocking. The + // active_installs guard is dropped when install_acp_runtime_blocking + // returns (Guard impl Drop) — so Phase 2's restart path runs outside + // the guard and cannot re-enter the mutex. + let runtime_id_clone = runtime_id.clone(); + let install_result = + tokio::task::spawn_blocking(move || install_acp_runtime_blocking(&runtime_id_clone)) + .await + .map_err(|e| format!("install task panicked: {e}"))??; + + if !install_result.success { + return Ok(install_result); + } + + // ── Phase 2: async restart of stuck agents ─────────────────────────────── + // + // Mirror set_global_agent_config: after a successful install, restart any + // local agents that were spawned in setup-listener mode for this runtime + // and whose readiness now computes Ready. Best-effort — errors are logged + // and returned as failed_restart_count without failing the command. + let (restarted_count, failed_restart_count) = + restart_setup_mode_agents_after_install(&app, &runtime_id).await; + + Ok(InstallRuntimeResult { + success: true, + steps: install_result.steps, + restarted_count, + failed_restart_count, + }) } /// Err(_) = infrastructure failure (panic, concurrency guard). /// Ok({success: false}) = an install step failed (stderr captured in steps). fn install_acp_runtime_blocking(runtime_id: &str) -> Result { + // Re-fetch the login-shell PATH so a Node.js installation that happened + // after app launch (or after a previous failed install) is visible to this + // run and to the subsequent discover_acp_providers call. + crate::managed_agents::refresh_login_shell_path(); + // Clear the resolve cache so newly-installed binaries are found. + crate::managed_agents::clear_resolve_cache(); + // Prevent concurrent installs for the same runtime. { let mut set = active_installs() @@ -63,9 +145,14 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result Result Result InstallStepResult { - let shell_path = crate::managed_agents::login_shell_path(); - let shell = if std::path::Path::new("/bin/zsh").exists() { - "/bin/zsh" - } else { - "/bin/bash" +// ── Post-install auto-restart (Phase 2 of install_acp_runtime) ─────────────── +// +// After a successful adapter install, restart any local agents that: +// 1. are local backend + have a live PID, +// 2. their effective command maps to the just-installed runtime, +// 3. were spawned in setup-listener mode (setup_mode stamp), AND +// 4. their readiness now computes Ready. +// +// Mirrors the two-phase shape of set_global_agent_config. + +/// Outcome of a single per-agent restart attempt during post-install restart. +#[derive(Debug)] +enum InstallRestartOutcome { + Restarted, + FailedAfterStop, + Skipped, +} + +/// Pure predicate: should this agent be restarted after an adapter install? +/// +/// Extracted for unit testing — callers must still re-verify under the lock. +/// The caller is responsible for computing `pid_alive` (via `process_is_running`) +/// before invoking this function, keeping the predicate OS-agnostic and testable +/// on all platforms. +/// +/// An agent qualifies iff: +/// - it is a local backend with a live PID (`pid_alive`), +/// - its effective command maps to `runtime_id`, +/// - it was **spawned in setup-listener mode** (`setup_mode`), AND +/// - its readiness **now computes `Ready`** (install fixed the blocker). +fn should_restart_after_install( + is_local: bool, + pid_alive: bool, + runtime_matches: bool, + setup_mode: bool, + now_ready: bool, +) -> bool { + is_local && pid_alive && runtime_matches && setup_mode && now_ready +} + +/// Restart all setup-mode agents whose runtime matches `runtime_id` and whose +/// readiness now computes Ready. Returns `(restarted_count, failed_restart_count)`. +async fn restart_setup_mode_agents_after_install( + app: &tauri::AppHandle, + runtime_id: &str, +) -> (u32, u32) { + use crate::{ + app_state::AppState, + managed_agents::{ + agent_readiness, known_acp_runtime, load_global_agent_config, load_managed_agents, + load_personas, record_agent_command, resolve_effective_agent_env, AgentReadiness, + BackendKind, + }, + }; + use tauri::Manager; + + // ── Pre-scan: collect candidate pubkeys without holding locks ──────────── + let state = app.state::(); + let owner_hex = match super::agents::workspace_owner_hex(&state) { + Ok(h) => h, + Err(e) => { + eprintln!( + "buzz-desktop: install_acp_runtime: failed to compute owner_hex for restart: {e}" + ); + return (0, 0); + } }; - let mut cmd = std::process::Command::new(shell); + let app_for_scan = app.clone(); + let runtime_id_owned = runtime_id.to_string(); + let candidates = tokio::task::spawn_blocking(move || { + let records = load_managed_agents(&app_for_scan).unwrap_or_default(); + let personas = load_personas(&app_for_scan).unwrap_or_default(); + let global = load_global_agent_config(&app_for_scan).unwrap_or_default(); + + // Read the runtimes map to check setup_mode stamps. + let state_inner = app_for_scan.state::(); + let runtimes = state_inner + .managed_agent_processes + .lock() + .unwrap_or_else(|e| e.into_inner()); + + records + .iter() + .filter(|record| { + let is_local = record.backend == BackendKind::Local; + let pid = record.runtime_pid; + let effective_cmd = record_agent_command(record, &personas); + let runtime_matches = + known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned); + let setup_mode = runtimes + .get(&record.pubkey) + .map(|p| p.setup_mode) + .unwrap_or(false); + let effective = resolve_effective_agent_env( + record, + &personas, + known_acp_runtime(&effective_cmd), + &global, + ); + let now_ready = matches!(agent_readiness(&effective), AgentReadiness::Ready); + let pid_alive = pid.is_some_and(crate::managed_agents::process_is_running); + should_restart_after_install( + is_local, + pid_alive, + runtime_matches, + setup_mode, + now_ready, + ) + }) + .map(|r| r.pubkey.clone()) + .collect::>() + }) + .await + .unwrap_or_default(); + + if candidates.is_empty() { + return (0, 0); + } + + let mut restarted_count: u32 = 0; + let mut failed_restart_count: u32 = 0; + + for pubkey in &candidates { + let outcome = restart_single_agent_after_install(app, pubkey, &owner_hex, runtime_id).await; + match outcome { + InstallRestartOutcome::Restarted => restarted_count += 1, + InstallRestartOutcome::FailedAfterStop => failed_restart_count += 1, + InstallRestartOutcome::Skipped => {} + } + } + + (restarted_count, failed_restart_count) +} + +/// Stop-then-start a single setup-mode agent after a successful adapter install. +/// +/// Mirrors `restart_local_agent_on_config_change` from `global_agent_config.rs`: +/// eligibility is re-verified under the store lock before the stop, then the +/// agent is restarted via `start_local_agent_with_preflight`. +async fn restart_single_agent_after_install( + app: &tauri::AppHandle, + pubkey: &str, + owner_hex: &str, + runtime_id: &str, +) -> InstallRestartOutcome { + use crate::{ + app_state::AppState, + managed_agents::{ + agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, + load_global_agent_config, load_managed_agents, load_personas, process_is_running, + record_agent_command, resolve_effective_agent_env, save_managed_agents, + stop_managed_agent_process, sync_managed_agent_processes, AgentReadiness, BackendKind, + }, + }; + use tauri::Manager; + + let app_for_stop = app.clone(); + let pubkey_owned = pubkey.to_string(); + let runtime_id_owned = runtime_id.to_string(); + + let stop_result = tokio::task::spawn_blocking(move || { + let state = app_for_stop.state::(); + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| format!("failed to acquire store lock: {e}"))?; + + let mut records = load_managed_agents(&app_for_stop)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| format!("failed to acquire runtimes lock: {e}"))?; + + // Sync process state so PID liveness reflects current reality. + let (sync_changed, _) = sync_managed_agent_processes( + &mut records, + &mut runtimes, + ¤t_instance_id(&app_for_stop), + ); + if sync_changed { + save_managed_agents(&app_for_stop, &records)?; + } + + // Re-verify eligibility under lock. + let record = records + .iter() + .find(|r| r.pubkey == pubkey_owned) + .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; + + if record.backend != BackendKind::Local { + return Err(format!("agent {pubkey_owned} is no longer a local agent")); + } + let Some(pid) = record.runtime_pid else { + return Err(format!( + "agent {pubkey_owned} no longer has a recorded PID after sync" + )); + }; + if !process_is_running(pid) { + return Err(format!( + "agent {pubkey_owned} process {pid} is no longer running" + )); + } + + let personas = load_personas(&app_for_stop).unwrap_or_default(); + let global = load_global_agent_config(&app_for_stop).unwrap_or_default(); + + let effective_cmd = record_agent_command(record, &personas); + let runtime_matches = + known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned); + if !runtime_matches { + return Err(format!( + "agent {pubkey_owned} runtime no longer matches {runtime_id_owned} under lock" + )); + } + + let setup_mode = runtimes + .get(&pubkey_owned) + .map(|p| p.setup_mode) + .unwrap_or(false); + if !setup_mode { + return Err(format!( + "agent {pubkey_owned} is not in setup mode under lock — skipping" + )); + } + + let runtime_meta = known_acp_runtime(&effective_cmd); + let effective = resolve_effective_agent_env(record, &personas, runtime_meta, &global); + if !matches!(agent_readiness(&effective), AgentReadiness::Ready) { + return Err(format!( + "agent {pubkey_owned} readiness is still NotReady after install — not bouncing" + )); + } + + // Stop the process. + let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; + stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; + save_managed_agents(&app_for_stop, &records)?; + + Ok(()) + }) + .await; + + let stopped = match stop_result { + Ok(Ok(())) => true, + Ok(Err(e)) => { + eprintln!("buzz-desktop: install_acp_runtime: skipping restart of {pubkey}: {e}"); + false + } + Err(e) => { + eprintln!( + "buzz-desktop: install_acp_runtime: spawn_blocking failed for stop of {pubkey}: {e}" + ); + false + } + }; + + if !stopped { + return InstallRestartOutcome::Skipped; + } + + // Start via the normal preflight path — same as config-change restart. + { + use tauri::Manager; + let state = app.state::(); + match super::agents::start_local_agent_with_preflight(app, &state, pubkey, owner_hex, false) + .await + { + Ok(_) => { + eprintln!( + "buzz-desktop: install_acp_runtime: restarted setup-mode agent {pubkey} after install" + ); + InstallRestartOutcome::Restarted + } + Err(e) => { + eprintln!( + "buzz-desktop: install_acp_runtime: failed to start {pubkey} after install: {e}" + ); + // Persist last_error so the UI surfaces a diagnosable stopped state. + if let Err(save_err) = persist_last_error_on_install(app, pubkey, &e) { + eprintln!( + "buzz-desktop: install_acp_runtime: failed to persist last_error for {pubkey}: {save_err}" + ); + } + InstallRestartOutcome::FailedAfterStop + } + } + } +} + +/// Persist a `last_error` on the agent record under the store lock. +/// Best-effort: called only after a failed restart. +fn persist_last_error_on_install( + app: &tauri::AppHandle, + pubkey: &str, + error: &str, +) -> Result<(), String> { + use crate::{ + app_state::AppState, + managed_agents::{find_managed_agent_mut, load_managed_agents, save_managed_agents}, + }; + use tauri::Manager; + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| format!("failed to acquire store lock: {e}"))?; + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + record.last_error = Some(error.to_string()); + record.updated_at = crate::util::now_iso(); + save_managed_agents(app, &records) +} + +/// Build a login-shell `Command` for `command` with the hermit env vars +/// stripped and the user's PATH set. This is the single source of truth for +/// the shell selection and environment cleanup shared by `run_install_command` +/// and `resolve_npm_prefix` — keeping them in sync so the hermit-strip list +/// can't drift between the two paths. +/// +/// On Windows, resolves Git Bash via `resolve_bash_path` (skips `BUZZ_SHELL` +/// since install commands require bash syntax). Returns `Err` when no shell +/// can be found. +fn install_shell_command(command: &str) -> Result { + let shell: std::path::PathBuf = resolve_install_shell()?; + + let mut cmd = std::process::Command::new(&shell); cmd.args(["-l", "-c", command]); // Strip hermit env vars so npm/node use the user's normal registry and @@ -124,7 +557,7 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { cmd.env_remove("NPM_CONFIG_CACHE"); cmd.env_remove("COREPACK_HOME"); - if let Some(ref path) = shell_path { + if let Some(ref path) = crate::managed_agents::login_shell_path() { cmd.env("PATH", path); } @@ -142,6 +575,63 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { } } + // Suppress the console window on Windows. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + Ok(cmd) +} + +/// Resolve the shell binary for install commands. +/// +/// Unix: `/bin/zsh` if present, else `/bin/bash`. +/// Windows: Git Bash via `resolve_bash_path` — skips `BUZZ_SHELL` because install +/// commands use bash-only `-l -c` syntax. A `BUZZ_SHELL=pwsh` user gets a green +/// Doctor prereq (their agents work) but installs use the Git Bash fallback chain. +fn resolve_install_shell() -> Result { + #[cfg(not(windows))] + { + if std::path::Path::new("/bin/zsh").exists() { + return Ok(std::path::PathBuf::from("/bin/zsh")); + } + Ok(std::path::PathBuf::from("/bin/bash")) + } + + #[cfg(windows)] + { + install_shell_from(crate::managed_agents::git_bash::resolve_bash_path()) + } +} + +/// Pure mapping from a resolved bash path to the install-shell result. +/// `None` → `Err(GIT_BASH_INSTALL_HINT)`, `Some(path)` → `Ok(path)`. +#[cfg(windows)] +pub(crate) fn install_shell_from( + resolved: Option, +) -> Result { + resolved.ok_or_else(|| crate::managed_agents::git_bash::GIT_BASH_INSTALL_HINT.to_string()) +} + +fn run_install_command(step: &str, command: &str) -> InstallStepResult { + let mut cmd = match install_shell_command(command) { + Ok(cmd) => cmd, + Err(hint) => { + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: "no suitable shell found for install commands".to_string(), + exit_code: None, + hint: Some(hint), + }; + } + }; + let mut child = match cmd .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) @@ -157,6 +647,7 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { stdout: String::new(), stderr: format!("failed to spawn shell: {e}"), exit_code: None, + hint: None, }; } }; @@ -201,6 +692,10 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { unsafe { libc::kill(child_pid as i32, libc::SIGTERM); } + #[cfg(windows)] + { + let _ = crate::managed_agents::taskkill_tree(child_pid); + } drop(rx); let _ = wait_thread.join(); let _ = stdout_thread.join(); @@ -212,6 +707,7 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { stdout: String::new(), stderr: "install command timed out after 5 minutes".to_string(), exit_code: None, + hint: None, }; } @@ -227,6 +723,7 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { stdout: truncate_output(stdout), stderr: truncate_output(stderr_raw), exit_code: status.code(), + hint: None, }; } Ok(Err(e)) => { @@ -240,6 +737,7 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { stdout: String::new(), stderr: format!("failed to check process status: {e}"), exit_code: None, + hint: None, }; } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { @@ -258,6 +756,7 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { stdout: String::new(), stderr: "internal error: wait thread disconnected".to_string(), exit_code: None, + hint: None, }; } } @@ -291,27 +790,258 @@ fn floor_char_boundary(s: &str, mut index: usize) -> usize { index } -#[tauri::command] -pub fn discover_managed_agent_prereqs( - input: DiscoverManagedAgentPrereqsRequest, -) -> ManagedAgentPrereqsInfo { - let acp_command = input - .acp_command - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(DEFAULT_ACP_COMMAND); - let mcp_command = input - .mcp_command - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(""); +// ── npm EACCES preflight ────────────────────────────────────────────────────── - ManagedAgentPrereqsInfo { - acp: command_availability(acp_command), - mcp: command_availability(mcp_command), +/// Guidance text for the EACCES / unwritable-prefix case. +fn npm_eacces_guidance(command: &str) -> String { + format!( + "npm's global install directory isn't writable by your user.\n\ +\n\ +Fix (no sudo):\n\ + 1. Run: npm config set prefix ~/.npm-global\n\ + 2. Add to ~/.zprofile: export PATH=\"$HOME/.npm-global/bin:$PATH\"\n\ + 3. Restart Buzz, then click Install again.\n\ +\n\ +Or install manually, then click Refresh:\n\ + sudo {command}" + ) +} + +/// Guidance text shown when npm / Node.js is not found in the login-shell PATH. +const NPM_MISSING_HINT: &str = "Node.js / npm was not found. Install Node.js \ +(https://nodejs.org or your version manager), restart Buzz, then click Install again.\n\ +If npm works in your terminal, make sure your Node version manager is initialized in \ +~/.zprofile (not only ~/.zshrc) — Buzz resolves tools via non-interactive login shells."; + +/// Result of probing `npm prefix -g` in the hermit-stripped login shell. +#[cfg(unix)] +enum NpmPrefix { + /// npm responded with a parseable prefix path. + Found(std::path::PathBuf), + /// npm was not found, the spawn failed, the command returned a non-zero + /// exit, or the output could not be parsed. + Unavailable, + /// The probe exceeded the 30-second deadline (e.g. a version-manager init + /// that blocks on `/dev/tty`). The install should proceed so the stderr + /// classifier remains the backstop. + TimedOut, +} + +/// Spawn the same login shell used by `run_install_command` and run +/// `npm prefix -g` to discover where npm would install global packages. +#[cfg(unix)] +fn resolve_npm_prefix() -> NpmPrefix { + let mut cmd = match install_shell_command("npm prefix -g") { + Ok(cmd) => cmd, + Err(_) => return NpmPrefix::Unavailable, + }; + let mut child = match cmd + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(_) => return NpmPrefix::Unavailable, + }; + + // Drain stdout/stderr on background threads to prevent pipe-buffer deadlock. + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + let stdout_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut pipe) = stdout_pipe { + let _ = pipe.read_to_end(&mut buf); + } + buf + }); + let stderr_thread = std::thread::spawn(move || { + // Drain stderr so the child doesn't block on a full pipe. + if let Some(mut pipe) = stderr_pipe { + let _ = std::io::copy(&mut pipe, &mut std::io::sink()); + } + }); + + let child_pid = child.id(); + let (tx, rx) = std::sync::mpsc::channel(); + let wait_thread = std::thread::spawn(move || { + let status = child.wait(); + let _ = tx.send(status); + }); + + // 30-second timeout — plenty for `npm prefix -g`; intentionally shorter + // than the 5-minute install budget in `run_install_command`. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let raw_bytes: Option> = loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + // Timed out: send SIGTERM, clean up threads, signal the caller to + // fall through to the install path rather than abort. + unsafe { libc::kill(child_pid as i32, libc::SIGTERM) }; + drop(rx); + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + eprintln!( + "buzz: npm prefix probe timed out after 30s; \ + proceeding to install (stderr classifier is the backstop)" + ); + return NpmPrefix::TimedOut; + } + match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) { + Ok(Ok(status)) => { + let _ = wait_thread.join(); + let stdout = stdout_thread.join().unwrap_or_default(); + let _ = stderr_thread.join(); + break if status.success() { Some(stdout) } else { None }; + } + Ok(Err(_)) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + break None; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + } + }; + + let bytes = match raw_bytes { + Some(b) => b, + None => return NpmPrefix::Unavailable, + }; + let raw = String::from_utf8_lossy(&bytes).into_owned(); + // Version managers can print banner lines before the real prefix — take the + // last non-empty line to skip any preamble. + let prefix = match raw.lines().rfind(|l| !l.trim().is_empty()) { + Some(l) => l.trim().to_string(), + None => return NpmPrefix::Unavailable, + }; + if prefix.is_empty() { + return NpmPrefix::Unavailable; } + NpmPrefix::Found(std::path::PathBuf::from(prefix)) +} + +/// Check write access to a file-system path using the POSIX `access(2)` syscall. +#[cfg(unix)] +fn unix_is_writable(path: &std::path::Path) -> bool { + use std::os::unix::ffi::OsStrExt; + let bytes = path.as_os_str().as_bytes(); + let Ok(c_path) = std::ffi::CString::new(bytes) else { + return false; + }; + // SAFETY: `access` is a pure read-only syscall; we pass a valid NUL-terminated + // path and a standard flag constant. This mirrors the existing `setsid`/`kill` + // usage in this file. + unsafe { libc::access(c_path.as_ptr(), libc::W_OK) == 0 } +} + +/// Returns true when the directory where npm would write global packages is +/// writable by the current process user. +/// +/// On non-unix platforms always returns `true` — the EACCES preflight is a +/// no-op there; the stderr classifier still applies. +fn npm_install_target_is_writable(prefix: &std::path::Path) -> bool { + #[cfg(unix)] + { + // Probe the most specific candidate that exists; fall back up the tree. + for candidate in &[ + prefix.join("lib/node_modules"), + prefix.join("lib"), + prefix.to_path_buf(), + ] { + if candidate.exists() { + return unix_is_writable(candidate); + } + } + // Nothing exists — npm couldn't create it either. + unix_is_writable(prefix) + } + #[cfg(not(unix))] + { + let _ = prefix; + true + } +} + +/// Inspect `stderr` for known npm EACCES patterns and return actionable +/// guidance if matched, or `None` when the error is unrelated. +fn npm_eacces_hint(stderr: &str, command: &str) -> Option { + if stderr.contains("EACCES: permission denied") || stderr.contains("npm error EACCES") { + Some(npm_eacces_guidance(command)) + } else { + None + } +} + +/// Run the npm preflight before executing an npm global install command. +/// Returns `Some(failed InstallStepResult)` to abort, or `None` to proceed. +fn npm_preflight_check(step: &str, command: &str) -> Option { + #[cfg(unix)] + { + match resolve_npm_prefix() { + NpmPrefix::Unavailable => Some(InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: String::new(), + exit_code: None, + hint: Some(NPM_MISSING_HINT.to_string()), + }), + NpmPrefix::Found(prefix) if !npm_install_target_is_writable(&prefix) => { + Some(InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: format!( + "npm global prefix '{}' is not writable by the current user.", + prefix.display() + ), + exit_code: None, + hint: Some(npm_eacces_guidance(command)), + }) + } + // `Found` + writable, or `TimedOut` — proceed; let the install run and + // the stderr classifier serve as the backstop. + _ => None, + } + } + #[cfg(not(unix))] + { + let _ = (step, command); + None + } +} + +// ── end npm preflight ───────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn discover_managed_agent_prereqs( + input: DiscoverManagedAgentPrereqsRequest, +) -> Result { + tokio::task::spawn_blocking(move || { + let acp_command = input + .acp_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_ACP_COMMAND); + let mcp_command = input + .mcp_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + + ManagedAgentPrereqsInfo { + acp: command_availability(acp_command), + mcp: command_availability(mcp_command), + } + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) } #[tauri::command] @@ -334,3 +1064,459 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result Result, String> { + tokio::task::spawn_blocking(crate::managed_agents::discover_git_bash) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) +} diff --git a/desktop/src-tauri/src/commands/agent_logs.rs b/desktop/src-tauri/src/commands/agent_logs.rs new file mode 100644 index 0000000000..25d3d4d1c4 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_logs.rs @@ -0,0 +1,40 @@ +use tauri::{AppHandle, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + load_managed_agents, managed_agent_log_path, read_log_tail, BackendKind, + ManagedAgentLogResponse, + }, +}; + +#[tauri::command] +pub async fn get_managed_agent_log( + pubkey: String, + line_count: Option, + app: AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + if record.backend != BackendKind::Local { + return Err("logs are not available for remote agents".to_string()); + } + + let log_path = managed_agent_log_path(&app, &pubkey)?; + Ok(ManagedAgentLogResponse { + content: read_log_tail(&log_path, line_count.unwrap_or(120) as usize)?, + log_path: log_path.display().to_string(), + }) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} diff --git a/desktop/src-tauri/src/commands/agent_metric_archive.rs b/desktop/src-tauri/src/commands/agent_metric_archive.rs new file mode 100644 index 0000000000..43cfc7b082 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_metric_archive.rs @@ -0,0 +1,35 @@ +//! Build-time flag for agent-turn-metric archive default. +//! +//! When `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` is set at build time +//! (internal builds), `agent_metric_archive_default_enabled()` returns `true` +//! and the frontend auto-seeds an `owner_p` save subscription for kind 44200 +//! (agent turn metrics) on first run for the current identity. +//! +//! OSS builds (env var unset) return `false` — no auto-seeding, user opts in +//! manually via the Local Archive settings card. + +/// Returns `true` when an internal build has agent-turn-metric archive +/// default-on. +/// +/// The frontend calls this once at startup to decide whether to seed the +/// `owner_p` [44200] save subscription. The result is stable for the lifetime +/// of the binary — it is baked at compile time. +#[tauri::command] +pub fn agent_metric_archive_default_enabled() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_agent_metric_archive_default_enabled_returns_false_in_oss_build() { + // In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT + // baked in), this must return false. + assert!( + !agent_metric_archive_default_enabled(), + "expected false in OSS/test build" + ); + } +} diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs new file mode 100644 index 0000000000..c61170a4a1 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -0,0 +1,76 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +use crate::managed_agents::{ + build_buzz_agent_provider_defaults, default_agent_workdir, known_acp_runtime, + redact_env_values_in, AgentModelsResponse, +}; + +use super::agent_models::normalize_agent_models; + +pub(super) async fn run_agent_models_command( + resolved_acp: PathBuf, + agent_command: String, + agent_args: Vec, + persisted_model: Option, + merged_env: BTreeMap, +) -> Result { + // Clone the env map for redaction below — `merged_env` is moved + // into the spawn_blocking closure and we still need the values to + // scrub any user-supplied secrets that the child surfaces in stderr. + let env_for_redaction = merged_env.clone(); + + // Use spawn_blocking because the desktop Tauri crate doesn't enable + // tokio's `process` feature. std::process::Command is synchronous + // but fine for a short-lived subprocess (~2-5s). + let output = tokio::task::spawn_blocking(move || { + let mut cmd = std::process::Command::new(&resolved_acp); + if let Some(home) = default_agent_workdir() { + cmd.current_dir(home); + } + if let Some(ref path) = crate::managed_agents::login_shell_path() { + cmd.env("PATH", path); + } + cmd.arg("models") + .arg("--json") + .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) + .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); + if let Some(meta) = known_acp_runtime(&agent_command) { + for (key, value) in meta.default_env { + if std::env::var(key).is_err() { + cmd.env(key, value); + } + } + } + // Mirror runtime spawn: internal builds may bake provider/model + // defaults. User-provided env below still wins. + build_buzz_agent_provider_defaults(&mut cmd); + // User env layering — written LAST so it overrides any Buzz-set env above. + for (k, v) in &merged_env { + cmd.env(k, v); + } + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|e| format!("failed to spawn buzz-acp models: {e}")) + }) + .await + .map_err(|e| format!("model discovery task failed: {e}"))? + .map_err(|e: String| e)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Scrub any user-supplied env values before surfacing stderr to + // the frontend — persona/agent env_vars may carry API keys that + // a failing child process echoed back. + let stderr_redacted = redact_env_values_in(stderr.as_ref(), &env_for_redaction); + return Err(format!( + "buzz-acp models failed (exit {}): {stderr_redacted}", + output.status.code().unwrap_or(-1) + )); + } + + let raw: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("failed to parse model JSON: {e}"))?; + + Ok(normalize_agent_models(&raw, persisted_model)) +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 13d27a87d7..97772b12a5 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -1,21 +1,20 @@ -use std::{ - collections::{BTreeMap, HashSet}, - path::PathBuf, -}; +use std::collections::{BTreeMap, HashSet}; use nostr::Keys; use serde::Deserialize; use tauri::{AppHandle, State}; +use super::agent_model_process::run_agent_models_command; + use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, current_instance_id, default_agent_workdir, - discovery_env_with_baked_floor, find_managed_agent_mut, known_acp_runtime, - load_managed_agents, load_personas, managed_agent_avatar_url, missing_command_message, - normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes, - try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, - UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, + build_managed_agent_summary, current_instance_id, discovery_env_with_baked_floor, + find_managed_agent_mut, known_acp_runtime, load_managed_agents, load_personas, + managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, + save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, + AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, + DEFAULT_ACP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -62,11 +61,7 @@ pub async fn get_agent_models( // so model discovery runs against the persona's current harness, not the // frozen record snapshot. An explicit per-agent override wins. let personas = load_personas(&app).unwrap_or_default(); - let effective_command = crate::managed_agents::effective_agent_command( - record.persona_id.as_deref(), - &personas, - record.agent_command_override.as_deref(), - ); + let effective_command = crate::managed_agents::record_agent_command(record, &personas); let args = normalize_agent_args(&effective_command, record.agent_args.clone()); @@ -225,6 +220,51 @@ pub async fn discover_agent_models( let merged_env = crate::managed_agents::merged_user_env(&derived_env, &input.env_vars); let merged_env = discovery_env_with_baked_floor(merged_env); + // Buzz shared compute discovery must not depend on the local OpenAI ingress: that + // client endpoint is started only after a live target is selected. + #[cfg(feature = "mesh-llm")] + if input.provider.as_deref().map(str::trim) + == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) + { + let events = crate::relay::query_relay( + &state, + &[ + crate::mesh_llm::mesh_status_filter(), + crate::mesh_llm::relay_membership_filter(), + ], + ) + .await + .map_err(|error| format!("Buzz shared compute model discovery failed: {error}"))?; + let availability = crate::mesh_llm::availability_from_events(events); + if availability.models.is_empty() { + return Err(availability.reason.unwrap_or_else(|| { + "No live Buzz shared compute models are available".to_string() + })); + } + return Ok(AgentModelsResponse { + agent_name: crate::managed_agents::RELAY_MESH_PROVIDER_ID.to_string(), + agent_version: "relay-availability".to_string(), + models: availability + .models + .into_iter() + .map(|model| AgentModelInfo { + id: model.id, + name: model.name, + description: None, + }) + .collect(), + agent_default_model: None, + selected_model: None, + supports_switching: true, + }); + } + #[cfg(not(feature = "mesh-llm"))] + if input.provider.as_deref().map(str::trim) + == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) + { + return Err("Buzz shared compute is not available in this build".to_string()); + } + if let Some(models) = discover_openai_compatible_models( &state.http_client, input.provider.as_deref(), @@ -366,16 +406,16 @@ fn openai_dated_snapshot_alias(id: &str) -> Option { fn openai_model_display_name(id: &str) -> String { let canonical = openai_dated_snapshot_alias(id).unwrap_or_else(|| id.to_string()); if let Some(rest) = canonical.strip_prefix("chatgpt-") { - return format!("ChatGPT {}", title_case_model_suffix(rest, false)); + return format!("ChatGPT {}", title_case_model_suffix(rest)); } if let Some(rest) = canonical.strip_prefix("gpt-") { - return format!("GPT-{}", title_case_model_suffix(rest, true)); + return format!("GPT-{}", title_case_model_suffix(rest)); } canonical } -fn title_case_model_suffix(value: &str, preserve_first_separator: bool) -> String { +fn title_case_model_suffix(value: &str) -> String { value .split('-') .enumerate() @@ -390,9 +430,7 @@ fn title_case_model_suffix(value: &str, preserve_first_separator: bool) -> Strin part.to_string() }; - if preserve_first_separator && index == 0 { - part - } else if index == 0 { + if index == 0 { part } else { format!(" {part}") @@ -447,14 +485,23 @@ async fn discover_openai_compatible_models( env: &BTreeMap, selected_model: Option, ) -> Result, String> { - if !is_openai_compatible_provider(provider) { + let relay_mesh = provider.map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID); + if !relay_mesh && !is_openai_compatible_provider(provider) { return Ok(None); } - let api_key = env_or_process_value(env, "OPENAI_COMPAT_API_KEY") - .ok_or_else(|| "config: OPENAI_COMPAT_API_KEY required".to_string())?; + let api_key = if relay_mesh { + crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string() + } else { + env_or_process_value(env, "OPENAI_COMPAT_API_KEY") + .ok_or_else(|| "config: OPENAI_COMPAT_API_KEY required".to_string())? + }; let redaction_env = redaction_env_with_value(env, "OPENAI_COMPAT_API_KEY", &api_key); - let url = openai_compatible_models_url_for_discovery(env); + let url = if relay_mesh { + format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL) + } else { + openai_compatible_models_url_for_discovery(env) + }; let response = client .get(&url) .bearer_auth(&api_key) @@ -644,12 +691,14 @@ fn is_databricks_provider(provider: Option<&str>) -> bool { .map(str::trim) .map(str::to_ascii_lowercase) .as_deref(), - Some("databricks" | "databricks_v2") + Some("databricks" | "databricks_v2" | "databricks-v2") ) } fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider { - if provider.trim().eq_ignore_ascii_case("databricks_v2") { + if provider.trim().eq_ignore_ascii_case("databricks_v2") + || provider.trim().eq_ignore_ascii_case("databricks-v2") + { buzz_agent_pkg::config::Provider::DatabricksV2 } else { buzz_agent_pkg::config::Provider::Databricks @@ -717,75 +766,6 @@ async fn discover_databricks_models( })) } -async fn run_agent_models_command( - resolved_acp: PathBuf, - agent_command: String, - agent_args: Vec, - persisted_model: Option, - merged_env: BTreeMap, -) -> Result { - // Clone the env map for redaction below — `merged_env` is moved - // into the spawn_blocking closure and we still need the values to - // scrub any user-supplied secrets that the child surfaces in stderr. - let env_for_redaction = merged_env.clone(); - - // Use spawn_blocking because the desktop Tauri crate doesn't enable - // tokio's `process` feature. std::process::Command is synchronous - // but fine for a short-lived subprocess (~2-5s). - let output = tokio::task::spawn_blocking(move || { - let mut cmd = std::process::Command::new(&resolved_acp); - if let Some(home) = default_agent_workdir() { - cmd.current_dir(home); - } - if let Some(ref path) = crate::managed_agents::login_shell_path() { - cmd.env("PATH", path); - } - cmd.arg("models") - .arg("--json") - .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) - .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); - if let Some(meta) = known_acp_runtime(&agent_command) { - for (key, value) in meta.default_env { - if std::env::var(key).is_err() { - cmd.env(key, value); - } - } - } - // Mirror runtime spawn: internal builds may bake provider/model - // defaults. User-provided env below still wins. - crate::managed_agents::build_buzz_agent_provider_defaults(&mut cmd); - // User env layering — written LAST so it overrides any Buzz-set env above. - for (k, v) in &merged_env { - cmd.env(k, v); - } - cmd.stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .output() - .map_err(|e| format!("failed to spawn buzz-acp models: {e}")) - }) - .await - .map_err(|e| format!("model discovery task failed: {e}"))? - .map_err(|e: String| e)?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - // Scrub any user-supplied env values before surfacing stderr to - // the frontend — persona/agent env_vars may carry API keys that - // a failing child process echoed back. - let stderr_redacted = - crate::managed_agents::redact_env_values_in(stderr.as_ref(), &env_for_redaction); - return Err(format!( - "buzz-acp models failed (exit {}): {stderr_redacted}", - output.status.code().unwrap_or(-1) - )); - } - - let raw: serde_json::Value = serde_json::from_slice(&output.stdout) - .map_err(|e| format!("failed to parse model JSON: {e}"))?; - - Ok(normalize_agent_models(&raw, persisted_model)) -} - /// Update mutable fields on an existing managed agent record. /// /// Does NOT auto-restart the agent. Runtime config changes (system prompt, @@ -833,19 +813,12 @@ pub async fn update_managed_agent( if let Some(prompt_update) = input.system_prompt { record.system_prompt = prompt_update; } - if let Some(toolsets_update) = input.mcp_toolsets { - record.mcp_toolsets = toolsets_update - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(str::to_string); - } if let Some(parallelism) = input.parallelism { record.parallelism = parallelism; } - if let Some(turn_timeout_seconds) = input.turn_timeout_seconds { - record.turn_timeout_seconds = turn_timeout_seconds; - } + // turn_timeout_seconds is intentionally not applied here — + // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness. + // Use idle_timeout_seconds or max_turn_duration_seconds instead. // Store the relay override exactly as supplied (trimmed). An explicit // value pins the agent; empty falls back to the workspace relay at // read-time. A name-only edit (relay_url == None) leaves the pin intact. @@ -855,30 +828,50 @@ pub async fn update_managed_agent( if let Some(acp_command) = input.acp_command { record.acp_command = acp_command; } - // Harness edit: the persona's runtime is authoritative, so we persist an - // explicit `agent_command_override` ONLY when the user picks a command - // that diverges from the persona. An empty/whitespace value (the - // "Inherit from persona" sentinel) clears the pin back to `None`. A - // name-only edit (`agent_command == None`) leaves the pin intact. + // Harness edit: the persona's runtime is authoritative, so an explicit + // `agent_command_override` is persisted ONLY when the user picks a + // command that diverges from the persona, and the empty/whitespace + // "Inherit from persona" sentinel clears both the pin and the + // materialized record runtime. A name-only edit + // (`agent_command == None`) leaves the pin intact. `harness_override` + // threads the user's explicit intent — see `apply_agent_command_update` + // and `update_time_agent_command_override` for the full resolution + // rules. if let Some(agent_command) = input.agent_command { let personas = load_personas(&app).unwrap_or_default(); - record.agent_command_override = crate::managed_agents::divergent_agent_command_override( - record.persona_id.as_deref(), + crate::managed_agents::apply_agent_command_update( + record, &personas, - Some(&agent_command), + &agent_command, + input.harness_override, ); } if let Some(agent_args) = input.agent_args { record.agent_args = agent_args; } - if let Some(mcp_command) = input.mcp_command { - record.mcp_command = mcp_command; - } + // mcp_command is intentionally not applied here — the effective MCP + // command is always catalog-derived (known_acp_runtime at spawn time) + // and the per-record field is never read by the runtime. if let Some(env_vars) = input.env_vars { crate::managed_agents::validate_user_env_keys(&env_vars)?; record.env_vars = env_vars; } + // Native provider/model fields are authoritative. Keep the typed marker + // derived for new records while retaining legacy typed records for + // non-native providers. + if record.provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { + let model_ref = record + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID) + .to_string(); + record.model = Some(model_ref.clone()); + record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref }); + } + // Inbound author gate: merge patch onto current values, then validate // the merged state. This lets a single update switch to Allowlist AND // supply pubkeys atomically. @@ -930,11 +923,7 @@ pub async fn update_managed_agent( // not the frozen snapshot, so an inherited harness picks the right // default avatar. let personas = load_personas(&app).unwrap_or_default(); - let effective_command = crate::managed_agents::effective_agent_command( - record.persona_id.as_deref(), - &personas, - record.agent_command_override.as_deref(), - ); + let effective_command = crate::managed_agents::record_agent_command(record, &personas); let avatar_url = record .avatar_url .clone() @@ -989,7 +978,7 @@ pub async fn update_managed_agent( /// /// Merges models from both ACP paths (stable configOptions + unstable SessionModelState), /// deduplicates by ID (stable takes precedence), and returns a unified list. -fn normalize_agent_models( +pub(super) fn normalize_agent_models( raw: &serde_json::Value, persisted_model: Option, ) -> AgentModelsResponse { diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 2fe72e6e1a..0f99927c9e 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -219,6 +219,39 @@ fn saved_agent_model_discovery_uses_record_snapshot() { // Parse/filter/pagination tests live in crates/buzz-agent/src/catalog.rs // (they moved there with the Option C refactor). +// --------------------------------------------------------------------------- +// Dead-knob guards: mcp_command and turn_timeout_seconds +// --------------------------------------------------------------------------- + +#[test] +fn update_request_mcp_command_parses_for_wire_compat() { + // UpdateManagedAgentRequest accepts mcpCommand for backward-compatibility + // with frontends that still send it: the deprecated field must keep + // parsing cleanly. Nothing consumes it — the patching loop in + // update_managed_agent has no mcp_command arm (the effective MCP command + // is always catalog-derived at spawn). That absent-arm invariant lives in + // the code, not in this test: it only guards the wire shape. + let req: crate::managed_agents::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey": "abc", "mcpCommand": "user-override"}"#) + .expect("request with deprecated mcpCommand parses"); + assert_eq!(req.mcp_command.as_deref(), Some("user-override")); +} + +#[test] +fn update_request_turn_timeout_parses_for_wire_compat() { + // UpdateManagedAgentRequest accepts turnTimeoutSeconds for + // backward-compatibility with frontends that still send it: the deprecated + // field must keep parsing cleanly. Nothing consumes it — the patching loop + // in update_managed_agent has no turn_timeout_seconds arm + // (BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness). That + // absent-arm invariant lives in the code, not in this test: it only + // guards the wire shape. + let req: crate::managed_agents::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey": "abc", "turnTimeoutSeconds": 9999}"#) + .expect("request with deprecated turnTimeoutSeconds parses"); + assert_eq!(req.turn_timeout_seconds, Some(9999)); +} + #[test] fn is_databricks_provider_matches_both_variants() { assert!(is_databricks_provider(Some("databricks"))); diff --git a/desktop/src-tauri/src/commands/agent_providers.rs b/desktop/src-tauri/src/commands/agent_providers.rs index a903e4bbaf..178ec0bb6d 100644 --- a/desktop/src-tauri/src/commands/agent_providers.rs +++ b/desktop/src-tauri/src/commands/agent_providers.rs @@ -1,14 +1,18 @@ use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo}; #[tauri::command] -pub fn discover_backend_providers() -> Vec { - discover_provider_candidates() - .into_iter() - .map(|(id, path)| BackendProviderInfo { - id, - binary_path: path.display().to_string(), - }) - .collect() +pub async fn discover_backend_providers() -> Result, String> { + tokio::task::spawn_blocking(|| { + discover_provider_candidates() + .into_iter() + .map(|(id, path)| BackendProviderInfo { + id, + binary_path: path.display().to_string(), + }) + .collect() + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index b1cb928220..54b52bd197 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager}; use crate::{ app_state::AppState, @@ -11,42 +11,91 @@ use crate::{ }; #[tauri::command] -pub fn set_managed_agent_start_on_app_launch( +pub async fn set_managed_agent_start_on_app_launch( pubkey: String, start_on_app_launch: bool, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + + let (sync_changed, exited_pubkeys) = + sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { + save_managed_agents(&app, &records)?; + } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } + + { + let record = find_managed_agent_mut(&mut records, &pubkey)?; + record.start_on_app_launch = start_on_app_launch; + record.updated_at = now_iso(); + } - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_session_cache(pubkey); - } - - { - let record = find_managed_agent_mut(&mut records, &pubkey)?; - record.start_on_app_launch = start_on_app_launch; - record.updated_at = now_iso(); - } - - save_managed_agents(&app, &records)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary(&app, record, &runtimes, &personas) + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let personas = load_personas(&app).unwrap_or_default(); + build_managed_agent_summary(&app, record, &runtimes, &personas) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +#[tauri::command] +pub async fn set_managed_agent_auto_restart( + pubkey: String, + auto_restart_on_config_change: bool, + app: AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + + let (sync_changed, exited_pubkeys) = + sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { + save_managed_agents(&app, &records)?; + } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } + + { + let record = find_managed_agent_mut(&mut records, &pubkey)?; + record.auto_restart_on_config_change = auto_restart_on_config_change; + record.updated_at = now_iso(); + } + + save_managed_agents(&app, &records)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let personas = load_personas(&app).unwrap_or_default(); + build_managed_agent_summary(&app, record, &runtimes, &personas) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index df890931fa..1718d54de2 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,13 +6,12 @@ use crate::{ managed_agents::{ build_managed_agent_summary, current_instance_id, discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, - managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir, - normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary, - save_managed_agents, start_managed_agent_process, stop_managed_agent_process, - sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, - CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse, - ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, - DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + load_teams, managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, + provider_deploy, resolve_provider_binary, save_managed_agents, start_managed_agent_process, + stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest, + validate_provider_config, BackendKind, CreateManagedAgentRequest, + CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -21,7 +20,7 @@ use crate::{ /// Read the workspace owner's pubkey hex from app state without holding the /// lock for longer than necessary. Used to populate `BUZZ_ACP_AGENT_OWNER` /// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. -fn workspace_owner_hex(state: &AppState) -> Result { +pub(super) fn workspace_owner_hex(state: &AppState) -> Result { let keys = state.keys.lock().map_err(|e| e.to_string())?; Ok(keys.public_key().to_hex()) } @@ -61,7 +60,7 @@ pub(super) fn retain_managed_agent_pending( let content = serde_json::to_string(&agent_event_content(record)) .map_err(|e| format!("failed to serialize managed-agent content: {e}"))?; let (owner_pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; let owner_pubkey = keys.public_key().to_hex(); let existing = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; @@ -108,7 +107,11 @@ pub(super) fn retain_managed_agent_pending( /// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a /// failure is logged and swallowed so a retention hiccup never blocks the /// disk-authoritative delete. -fn tombstone_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubkey: &str) { +pub(super) fn tombstone_managed_agent_pending( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, +) { use crate::managed_agents::{ agent_events::build_agent_delete, retention::{ @@ -123,7 +126,7 @@ fn tombstone_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubk let result = (|| -> Result<(), String> { let (owner_pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; let owner_pubkey = keys.public_key().to_hex(); let event = build_agent_delete(agent_pubkey, &owner_pubkey)? .sign_with_keys(&keys) @@ -162,10 +165,10 @@ fn normalize_relay_mesh( let model_ref = config.model_ref.trim(); if model_ref.is_empty() { - return Err("relay mesh modelRef is required".to_string()); + return Err("Buzz shared compute model is required".to_string()); } if backend != &BackendKind::Local { - return Err("relay mesh agents must use the local backend".to_string()); + return Err("Buzz shared compute agents must use the local backend".to_string()); } Ok(Some(RelayMeshConfig { @@ -215,7 +218,7 @@ async fn ensure_relay_mesh_for_record( Ok(()) } -async fn start_local_agent_with_preflight( +pub(super) async fn start_local_agent_with_preflight( app: &AppHandle, state: &AppState, pubkey: &str, @@ -256,21 +259,15 @@ async fn start_local_agent_with_preflight( } // Re-snapshot the persona onto the record at every spawn so the agent always // starts with the current persona config (system_prompt, model, provider, - // env_vars). This clears the "out of date" drift badge without requiring a - // delete+recreate. Agent-level env_vars overrides still win (persona_snapshot - // layers persona env under agent overrides). + // runtime). This clears the "out of date" drift badge without requiring a + // delete+recreate. See `apply_persona_snapshot` for the precedence and + // env-override self-heal rules. + // Load personas once: used for snapshot application below and summary build + // at the end — avoids a second disk read for the same file in the same call. + let personas = load_personas(app).unwrap_or_default(); if let Some(persona_id) = record.persona_id.clone() { - let personas = load_personas(app).unwrap_or_default(); if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { - let snapshot = - crate::managed_agents::persona_events::persona_snapshot(persona, &record.env_vars); - if let Some(prompt) = snapshot.system_prompt { - record.system_prompt = Some(prompt); - } - record.model = snapshot.model; - record.provider = snapshot.provider; - record.env_vars = snapshot.env_vars; - record.persona_source_version = Some(snapshot.source_version); + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); record.updated_at = crate::util::now_iso(); } } @@ -283,108 +280,9 @@ async fn start_local_agent_with_preflight( .iter() .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; - let personas = load_personas(app).unwrap_or_default(); build_managed_agent_summary(app, record, &runtimes, &personas) } -/// Build the standard agent JSON payload for provider deploy calls. -/// -/// Unlike local spawn (which uses only pinned `record.env_vars` for -/// determinism), provider deploy re-reads live persona env vars and -/// structured model/provider so remote agents receive current credentials -/// and the same authoritative values that local spawn derives from -/// `runtime_metadata_env_vars`. The only field still pinned is -/// `agent_command`/`agent_args` — those were captured at create time. -/// The only read-time resolution is `relay_url`: a blank pin resolves to -/// the active workspace relay here, matching the create-path contract. -/// -/// Fails closed when the private key is unavailable (keyring outage leaves -/// it empty after hydration): without this guard a provider deploy would -/// serialize `"private_key_nsec": ""` and launch the agent with no -/// identity — the same hazard the local spawn path refuses via -/// `spawn_key_refusal`. -fn build_deploy_payload( - app: &AppHandle, - state: &AppState, - record: &ManagedAgentRecord, -) -> Result { - // Fails closed when the private key is unavailable — same guard as local - // spawn. Without this, a keyring outage would serialize `"private_key_nsec": ""` - // and launch the agent with no identity. - if let Some(err) = crate::managed_agents::spawn_key_refusal(record) { - return Err(err); - } - - // Merge persona env_vars + agent env_vars for provider deploy. Provider - // deploy re-reads live persona env vars so remote agents receive current - // credentials; local spawn uses only pinned record.env_vars for determinism - // across restarts. Without this, provider-backed agents wouldn't receive - // credentials saved on the persona or the agent itself. - let persona_env = - crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?; - let merged_env = crate::managed_agents::merged_user_env(&persona_env, &record.env_vars); - - // Resolve the persona's structured provider/model so the remote provider - // receives the same authoritative values that local spawn derives from - // `runtime_metadata_env_vars`. Without this, remote deploy would rely on - // stale derived env copies in `env_vars` (or have no provider at all for - // imported personas whose derived keys were filtered at import time). - // - // Precedence mirrors local spawn: persona structured model is authoritative - // when present; the agent record's `model` is a fallback for personas that - // don't specify one (or when no persona is linked). - let (effective_model, effective_provider) = if let Some(pid) = record.persona_id.as_deref() { - let personas = load_personas(app).map_err(|e| { - format!( - "failed to load personas while building deploy payload for persona `{pid}`: {e}" - ) - })?; - let persona = personas - .into_iter() - .find(|p| p.id == pid) - .ok_or_else(|| format!("persona `{pid}` not found while building deploy payload"))?; - let model = persona.model.clone().or(record.model.clone()); - let provider = persona.provider; - (model, provider) - } else { - (record.model.clone(), None) - }; - - Ok(serde_json::json!({ - "name": &record.name, - // Resolve the per-agent pin against the active workspace relay here: - // this payload crosses the host boundary to a remote provider harness - // that has no notion of the desktop's workspace, so the blank→workspace - // fallback (otherwise applied at read-time in `effective_agent_relay_url`) - // must be materialized into a concrete URL before serializing. - "relay_url": crate::relay::effective_agent_relay_url( - &record.relay_url, - &relay_ws_url_with_override(state), - ), - "private_key_nsec": &record.private_key_nsec, - "auth_tag": &record.auth_tag, - "agent_command": &record.agent_command, - "agent_args": &record.agent_args, - "system_prompt": &record.system_prompt, - "model": effective_model, - // Structured provider from the persona record. Providers that don't - // yet read this field will fall back to env_vars or their own default - // — no protocol break. - "provider": effective_provider, - "turn_timeout_seconds": record.turn_timeout_seconds, - "idle_timeout_seconds": record.idle_timeout_seconds, - "max_turn_duration_seconds": record.max_turn_duration_seconds, - "parallelism": record.parallelism, - // Inbound author gate. Providers that don't yet read these fall back - // to the harness default (`owner-only`) — no protocol break. - "respond_to": record.respond_to, - "respond_to_allowlist": &record.respond_to_allowlist, - // Merged persona + agent env vars. Providers that don't read this - // field will simply ignore it — no protocol break. - "env_vars": merged_env, - })) -} - /// Deploy an agent to a provider backend. Resolves the binary, calls deploy via /// spawn_blocking, and persists the result (backend_agent_id or last_error). /// @@ -518,10 +416,13 @@ pub async fn create_managed_agent( // Validate & normalize the respond-to allowlist BEFORE any side effects. // The harness has its own validator (buzz-acp/src/config.rs) but we want // to catch malformed input at the boundary so the agent never tries to - // start with a list that will crash it on launch. + // start with a list that will crash it on launch. The mode/allowlist + // pairing (and the definition-default fallback) is resolved later at the + // mint site via `resolve_mint_behavioral_defaults`, where the linked + // definition is in hand. let respond_to_allowlist = crate::managed_agents::validate_respond_to_allowlist(&input.respond_to_allowlist)?; - if input.respond_to == crate::managed_agents::RespondTo::Allowlist + if input.respond_to == Some(crate::managed_agents::RespondTo::Allowlist) && respond_to_allowlist.is_empty() { return Err( @@ -593,7 +494,7 @@ pub async fn create_managed_agent( // Agents authenticate via the auth tag in their kind:0 profile event. // No tokens are minted. Fail closed: bad auth tag → don't create agent. let auth_tag = { - let owner_keys = state.keys.lock().map_err(|e| e.to_string())?; + let owner_keys = state.signing_keys()?; // Bridge nostr 0.37 → 0.36 (buzz-sdk) via hex round-trip. let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) .map_err(|e| format!("failed to bridge owner keys: {e}"))?; @@ -675,33 +576,26 @@ pub async fn create_managed_agent( .collect::>(), ); - let mcp_command = input - .mcp_command + // Derive MCP command exclusively from the runtime catalog — the + // per-record field is never read at spawn time so user-supplied input + // is silently discarded. Always sourcing from the catalog ensures + // new agents pick up the correct value without any stored override. + let mcp_command = match crate::managed_agents::known_acp_runtime(&agent_command) { + Some(p) => p.mcp_command.unwrap_or("").to_string(), + None => String::new(), + }; + + let team_id = input + .team_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else( - || match crate::managed_agents::known_acp_runtime(&agent_command) { - Some(p) => p.mcp_command.unwrap_or("").to_string(), - None => String::new(), - }, - ); - - // For pack-backed personas, resolve the installed pack path and the - // persona's internal name (slug). ACP's resolve_persona_by_name() - // matches on this internal name, NOT display_name. - let pack_metadata: Option<(std::path::PathBuf, String)> = - requested_persona_id.as_deref().and_then(|pid| { - let persona = personas.iter().find(|p| p.id == pid)?; - let team_id = persona.source_team.as_deref()?; - let slug = persona.source_team_persona_slug.as_deref()?; - let base = managed_agents_base_dir(&app).ok()?; - let team_path = base.join("teams").join(team_id); - // Use the validated slug stored during import — no need to - // re-resolve the pack. The slug is [a-zA-Z0-9_-]+ by construction. - Some((team_path, slug.to_owned())) - }); + .map(str::to_string); + if let Some(team_id) = &team_id { + if !load_teams(&app)?.iter().any(|team| &team.id == team_id) { + return Err(format!("team {team_id} not found")); + } + } // Resolve the avatar URL once at creation and persist it on the record. // Explicit input wins, then the persona's own avatar, then the runtime @@ -724,35 +618,51 @@ pub async fn create_managed_agent( // and deploy read these snapshotted fields, never the live persona, so // the agent stays on the config it was created with across restarts; // delete+respawn re-runs create and rewrites the snapshot. env_vars are - // pinned too — without that, persona credential edits would leak into a - // running agent on restart. Agent-level env overrides (input.env_vars) - // layer on top, matching spawn precedence (persona env < agent env). - let persona_snapshot = requested_persona_id.as_deref().and_then(|pid| { + // NOT pinned: `record.env_vars` holds agent-level overrides only + // (input.env_vars), and the live persona env is merged underneath at + // read time (spawn / readiness / deploy) so persona credential edits + // refresh on the next spawn like prompt/model/provider already do. + let linked_persona = requested_persona_id.as_deref().and_then(|pid| { load_personas(&app) .ok()? .into_iter() .find(|persona| persona.id == pid) - .map(|persona| { - crate::managed_agents::persona_events::persona_snapshot( - &persona, - &input.env_vars, - ) - }) }); + let persona_snapshot = linked_persona + .as_ref() + .map(crate::managed_agents::persona_events::persona_snapshot); let snapshot_prompt = persona_snapshot .as_ref() .and_then(|s| s.system_prompt.clone()); let snapshot_model = persona_snapshot.as_ref().and_then(|s| s.model.clone()); let snapshot_provider = persona_snapshot.as_ref().and_then(|s| s.provider.clone()); let snapshot_source_version = persona_snapshot.as_ref().map(|s| s.source_version.clone()); - let snapshot_env_vars = persona_snapshot - .map(|s| s.env_vars) - .unwrap_or_else(|| input.env_vars.clone()); + let effective_provider = snapshot_provider + .or_else(|| input.provider.as_deref().and_then(trim_to_optional_string)); + let mut effective_model = + snapshot_model.or_else(|| input.model.as_deref().and_then(trim_to_optional_string)); + if effective_provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) + && effective_model.is_none() + { + effective_model = Some(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID.to_string()); + } + + // Mint-time behavioral quad: explicit input wins, then the linked + // definition's NIP-AP defaults, then client defaults. The ONLY parse + // point for definition behavioral strings — fails loudly on a bad + // mode/range instead of minting an agent the author didn't describe. + let minted = crate::managed_agents::resolve_mint_behavioral_defaults( + input.respond_to, + respond_to_allowlist.clone(), + input.parallelism, + linked_persona.as_ref(), + )?; let record = crate::managed_agents::ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), persona_id: requested_persona_id.clone(), + team_id, private_key_nsec: private_key_nsec.clone(), auth_tag: auth_tag.clone(), relay_url: resolved_relay_url.clone(), @@ -768,17 +678,14 @@ pub async fn create_managed_agent( agent_command_override, agent_args, mcp_command, - turn_timeout_seconds: input - .turn_timeout_seconds - .filter(|seconds| *seconds > 0) - .unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS), + // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness; + // store the schema default only. Use idle_timeout_seconds or + // max_turn_duration_seconds for actual turn-length control. + turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, // 0 or None → harness uses its own default (320s idle, 3600s max), and the CLI also clamps 0 → minimum. idle_timeout_seconds: input.idle_timeout_seconds.filter(|s| *s > 0), max_turn_duration_seconds: input.max_turn_duration_seconds.filter(|s| *s > 0), - parallelism: input - .parallelism - .filter(|count| (1..=32).contains(count)) - .unwrap_or(DEFAULT_AGENT_PARALLELISM), + parallelism: minted.parallelism.unwrap_or(DEFAULT_AGENT_PARALLELISM), system_prompt: snapshot_prompt.or_else(|| { input .system_prompt @@ -787,47 +694,52 @@ pub async fn create_managed_agent( .filter(|value| !value.is_empty()) .map(str::to_string) }), - model: snapshot_model.or_else(|| { - input - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - }), - provider: snapshot_provider, + model: effective_model.clone(), + provider: effective_provider.clone(), persona_source_version: snapshot_source_version, - mcp_toolsets: input - .mcp_toolsets - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), // Provider agents are managed externally — force false. start_on_app_launch: if input.backend != BackendKind::Local { false } else { input.start_on_app_launch }, + auto_restart_on_config_change: true, runtime_pid: None, backend: input.backend.clone(), backend_agent_id: None, provider_binary_path, - // Team-backed personas: record path + internal slug so the runtime - // can resolve team config at startup. Must be the slug (e.g., "lep"), - // NOT the display_name — ACP's resolve_persona_by_name() matches slugs. - persona_team_dir: pack_metadata.as_ref().map(|(path, _)| path.clone()), - persona_name_in_team: pack_metadata.as_ref().map(|(_, name)| name.clone()), - env_vars: snapshot_env_vars, + persona_team_dir: None, + persona_name_in_team: None, + env_vars: input.env_vars.clone(), created_at: now_iso(), updated_at: now_iso(), last_started_at: None, last_stopped_at: None, last_exit_code: None, last_error: None, - respond_to: input.respond_to, - respond_to_allowlist: respond_to_allowlist.clone(), - relay_mesh: relay_mesh.clone(), + last_error_code: None, + respond_to: minted.respond_to, + respond_to_allowlist: minted.respond_to_allowlist.clone(), + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: if effective_provider.as_deref() + == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) + { + effective_model + .clone() + .map(|model_ref| RelayMeshConfig { model_ref }) + } else { + relay_mesh.clone() + }, }; records.push(record); @@ -886,9 +798,13 @@ pub async fn create_managed_agent( // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── // Use the avatar persisted on the record so the published profile and any // later reconciliation agree on the same value. + let profile_relay_url = crate::relay::effective_agent_relay_url( + &resolved_relay_url, + &relay_ws_url_with_override(&state), + ); let profile_sync_error = (sync_managed_agent_profile( &state, - &resolved_relay_url, + &profile_relay_url, &agent_keys, &name, resolved_avatar_url.as_deref(), @@ -955,27 +871,6 @@ pub async fn create_managed_agent( } /// Data needed for background profile reconciliation after agent start. -pub(crate) struct ProfileReconcileData { - pub(crate) private_key_nsec: String, - pub(crate) name: String, - pub(crate) relay_url: String, - /// Expected avatar URL for the published profile. `None` for legacy records - /// that predate the `avatar_url` field — these will be backfilled from the - /// relay's existing kind:0 profile on first reconciliation. - pub(crate) avatar_url: Option, - pub(crate) auth_tag: Option, - /// The agent's pubkey (hex). Needed to update the persisted record during - /// avatar backfill migration. - pub(crate) pubkey: String, - /// The agent's command (e.g. "goose"). Used as fallback when no profile - /// exists on the relay during avatar backfill. - pub(crate) agent_command: String, - /// Persona ID if this agent was created from a persona. Used during avatar - /// backfill to recover the correct avatar from the persona record when the - /// relay profile has been corrupted. - pub(crate) persona_id: Option, -} - #[tauri::command] pub async fn start_managed_agent( pubkey: String, @@ -1022,11 +917,8 @@ pub async fn start_managed_agent( // profile reconcile (the create-time snapshot may be empty or stale for // a persona-inherited harness). let reconcile_personas = load_personas(&app).unwrap_or_default(); - let reconcile_effective_command = crate::managed_agents::effective_agent_command( - record.persona_id.as_deref(), - &reconcile_personas, - record.agent_command_override.as_deref(), - ); + let reconcile_effective_command = + crate::managed_agents::record_agent_command(record, &reconcile_personas); let reconcile = ProfileReconcileData { private_key_nsec: record.private_key_nsec.clone(), @@ -1119,142 +1011,6 @@ pub async fn start_managed_agent( result } -/// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no -/// stored `avatar_url`). -/// -/// Priority: the persona's avatar wins, because the old reconciliation code -/// could have overwritten the relay's kind:0 `picture` with the command default -/// — making the relay an unreliable source for persona-backed agents. Only fall -/// back to the relay's `picture`, then the command icon, for agents with no -/// persona avatar to recover from. -fn resolve_legacy_avatar( - persona_avatar: Option, - relay_picture: Option, - agent_command: &str, -) -> String { - persona_avatar - .or(relay_picture) - .or_else(|| managed_agent_avatar_url(agent_command)) - .unwrap_or_default() -} - -/// Reconcile an agent's kind:0 profile on the relay. -/// -/// Queries the relay for the agent's existing profile and re-publishes if missing -/// or stale (display_name or picture mismatch). This is fire-and-forget — errors -/// are returned to the caller for logging but never block agent startup. -/// -/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function -/// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar -/// over the relay's `picture`, since the old code may have corrupted the relay -/// profile — and persists the updated record. After backfill, normal -/// reconciliation proceeds. -/// -/// Query and publish target the relay returned by `effective_agent_relay_url` -/// for every agent regardless of backend: an explicit per-agent `relay_url` -/// wins, and a blank one falls back to the active workspace relay. This keeps -/// reconciliation following the session's relay for never-pinned agents while -/// honoring a deliberate pin wherever it points. -pub(crate) async fn reconcile_agent_profile( - state: &AppState, - app: &AppHandle, - agent_pubkey: &str, - data: &ProfileReconcileData, -) -> Result<(), String> { - use crate::relay::{query_agent_profile, sync_managed_agent_profile}; - - // An explicit per-agent relay wins; an empty one falls back to the active - // workspace relay. Resolved once and used for both the read and write-back. - let relay_url = crate::relay::effective_agent_relay_url( - &data.relay_url, - &relay_ws_url_with_override(state), - ); - - // Query the relay for the agent's existing kind:0 profile. - let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?; - - // Resolve the expected avatar — backfilling for legacy records that have no - // stored avatar_url yet. - let expected_avatar = match data.avatar_url.as_deref() { - Some(url) => url.to_string(), - None => { - // Legacy record: the relay profile may have been corrupted by the - // old reconciliation code (it overwrote the persona avatar with the - // command default), so the persona record is the authoritative source. - let persona_avatar = data.persona_id.as_ref().and_then(|pid| { - load_personas(app) - .ok()? - .into_iter() - .find(|p| p.id == *pid)? - .avatar_url - }); - - let backfilled = resolve_legacy_avatar( - persona_avatar, - existing.as_ref().and_then(|info| info.picture.clone()), - &data.agent_command, - ); - - // Persist the backfilled avatar so this migration only runs once. - if !backfilled.is_empty() { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) { - record.avatar_url = Some(backfilled.clone()); - save_managed_agents(app, &records)?; - } - } - - backfilled - } - }; - - if expected_avatar.is_empty() { - return Ok(()); - } - - if !profile_needs_sync(existing.as_ref(), &data.name, Some(&expected_avatar)) { - return Ok(()); - } - - let agent_keys = Keys::parse(&data.private_key_nsec) - .map_err(|e| format!("failed to parse agent keys: {e}"))?; - - sync_managed_agent_profile( - state, - &relay_url, - &agent_keys, - &data.name, - Some(&expected_avatar), - data.auth_tag.as_deref(), - ) - .await -} - -/// Decide whether a published profile is missing or stale relative to the -/// expected name and avatar. A missing profile always needs sync; a present -/// one is stale when either the display name or picture diverges. -fn profile_needs_sync( - existing: Option<&crate::relay::AgentProfileInfo>, - expected_name: &str, - expected_avatar: Option<&str>, -) -> bool { - match existing { - None => true, - Some(info) => { - let name_matches = info.display_name.as_deref() == Some(expected_name); - let picture_matches = info.picture.as_deref() == expected_avatar; - !name_matches || !picture_matches - } - } -} - -// Async so the blocking body (disk reads/writes + process termination) runs off -// the main UI thread via spawn_blocking. State is re-derived from the owned -// AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send). #[tauri::command] pub async fn stop_managed_agent( pubkey: String, @@ -1382,39 +1138,26 @@ pub async fn delete_managed_agent( .map_err(|e| format!("spawn_blocking failed: {e}"))? } -#[tauri::command] -pub fn get_managed_agent_log( - pubkey: String, - line_count: Option, - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let records = load_managed_agents(&app)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - if record.backend != BackendKind::Local { - return Err("logs are not available for remote agents".to_string()); - } - - let log_path = managed_agent_log_path(&app, &pubkey)?; - Ok(ManagedAgentLogResponse { - content: read_log_tail(&log_path, line_count.unwrap_or(120) as usize)?, - log_path: log_path.display().to_string(), - }) -} - // Remote agent shutdown is handled entirely by the frontend: // 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key) // 2. Harness sees it, exits gracefully, sets presence to "offline" // 3. Desktop's existing presence polling sees "offline" — UI updates automatically // No backend Tauri command needed. Presence IS the status. +#[path = "agents_deploy.rs"] +mod deploy; +use deploy::build_deploy_payload; +#[cfg(test)] +use deploy::deploy_payload_json; +#[cfg(test)] +pub(crate) use deploy::resolve_deploy_model_provider; + +#[path = "agents_profile.rs"] +mod profile; +#[cfg(test)] +use profile::{profile_needs_sync, resolve_legacy_avatar}; +pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData}; + #[cfg(test)] #[path = "agents_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs new file mode 100644 index 0000000000..be132225ea --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -0,0 +1,149 @@ +//! Provider deploy payload construction, split from `agents.rs` (file-size +//! guard). `build_deploy_payload` gathers live state; `deploy_payload_json` +//! is the pure serialization half so payload completeness stays testable. + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{load_personas, AgentDefinition, ManagedAgentRecord}, + relay::relay_ws_url_with_override, +}; + +/// Resolve the deploy-specific structured model/provider for a managed agent. +/// +/// Deploy uses **live-persona-first** precedence so remote agents receive +/// current config after a persona update, without requiring delete+recreate. +/// Unlike local spawn (which re-snapshots the persona onto `record` at the +/// start of every spawn), provider start does not re-snapshot — so the +/// record may hold a stale snapshot while the linked persona has moved on. +/// +/// Precedence: live-persona → record (snapshot fallback) → global. +/// Symmetric for both model and provider. +/// +/// Exported `pub(crate)` for unit testing. +pub(crate) fn resolve_deploy_model_provider<'a>( + record: &'a ManagedAgentRecord, + personas: &'a [AgentDefinition], + global: &'a crate::managed_agents::GlobalAgentConfig, +) -> (Option<&'a str>, Option<&'a str>) { + let live_persona = record + .persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)); + let model = live_persona + .and_then(|p| p.model.as_deref()) + .or(record.model.as_deref()) + .or(global.model.as_deref()); + let provider = live_persona + .and_then(|p| p.provider.as_deref()) + .or(record.provider.as_deref()) + .or(global.provider.as_deref()); + (model, provider) +} + +/// Build the standard agent JSON payload for provider deploy calls. +/// +/// Like local spawn, provider deploy re-reads live persona env vars and +/// structured model/provider so remote agents receive current credentials +/// and the same authoritative values that local spawn derives from +/// `runtime_metadata_env_vars`. The only field still pinned is +/// `agent_command`/`agent_args` — those were captured at create time. +/// The only read-time resolution is `relay_url`: a blank pin resolves to +/// the active workspace relay here, matching the create-path contract. +/// +/// Fails closed when the private key is unavailable (keyring outage leaves +/// it empty after hydration): without this guard a provider deploy would +/// serialize `"private_key_nsec": ""` and launch the agent with no +/// identity — the same hazard the local spawn path refuses via +/// `spawn_key_refusal`. +pub(super) fn build_deploy_payload( + app: &AppHandle, + state: &AppState, + record: &ManagedAgentRecord, +) -> Result { + // Fails closed when the private key is unavailable — same guard as local + // spawn. Without this, a keyring outage would serialize `"private_key_nsec": ""` + // and launch the agent with no identity. + if let Some(err) = crate::managed_agents::spawn_key_refusal(record) { + return Err(err); + } + + // Merge global + persona + agent env_vars for provider deploy — the same + // live-persona-under-overrides semantics as local spawn. Global env vars + // are the lowest user-settable layer: global < persona < agent (last-wins + // on key collision). Without this, provider-backed agents wouldn't receive + // credentials saved on the persona or the agent itself. + let global_config = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let global_env = global_config.env_vars.clone(); + let persona_env = + crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?; + // Merge: global < persona (persona wins over global). + let global_persona_merged = crate::managed_agents::merged_user_env(&global_env, &persona_env); + // Merge: global+persona < agent (agent wins over everything). + let merged_env = + crate::managed_agents::merged_user_env(&global_persona_merged, &record.env_vars); + + // Resolve the deploy-specific structured provider/model. Uses the deploy + // resolver with live-persona → record → global precedence. + let personas = load_personas(app).unwrap_or_default(); + let (effective_model, effective_provider) = + resolve_deploy_model_provider(record, &personas, &global_config); + let (effective_model, effective_provider) = ( + effective_model.map(str::to_string), + effective_provider.map(str::to_string), + ); + + Ok(deploy_payload_json( + record, + crate::relay::effective_agent_relay_url( + &record.relay_url, + &relay_ws_url_with_override(state), + ), + effective_model, + effective_provider, + merged_env, + )) +} + +/// Pure serialization half of [`build_deploy_payload`] — every field the +/// provider harness receives is deliberately listed here, so payload +/// completeness is testable without an `AppHandle`. +pub(super) fn deploy_payload_json( + record: &ManagedAgentRecord, + relay_url: String, + effective_model: Option, + effective_provider: Option, + merged_env: std::collections::BTreeMap, +) -> serde_json::Value { + serde_json::json!({ + "name": &record.name, + // Resolve the per-agent pin against the active workspace relay here: + // this payload crosses the host boundary to a remote provider harness + // that has no notion of the desktop's workspace, so the blank→workspace + // fallback (otherwise applied at read-time in `effective_agent_relay_url`) + // must be materialized into a concrete URL before serializing. + "relay_url": relay_url, + "private_key_nsec": &record.private_key_nsec, + "auth_tag": &record.auth_tag, + "agent_command": &record.agent_command, + "agent_args": &record.agent_args, + "system_prompt": &record.system_prompt, + "model": effective_model, + // Structured provider from the persona record. Providers that don't + // yet read this field will fall back to env_vars or their own default + // — no protocol break. + "provider": effective_provider, + "turn_timeout_seconds": record.turn_timeout_seconds, + "idle_timeout_seconds": record.idle_timeout_seconds, + "max_turn_duration_seconds": record.max_turn_duration_seconds, + "parallelism": record.parallelism, + // Inbound author gate. Providers that don't yet read these fall back + // to the harness default (`owner-only`) — no protocol break. + "respond_to": record.respond_to, + "respond_to_allowlist": &record.respond_to_allowlist, + // Merged persona + agent env vars. Providers that don't read this + // field will simply ignore it — no protocol break. + "env_vars": merged_env, + }) +} diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs new file mode 100644 index 0000000000..236fd9f4cc --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -0,0 +1,170 @@ +//! Agent kind:0 profile reconciliation — split from `agents.rs` (file-size +//! guard). Owns the reconcile data carrier, the legacy-avatar backfill, and +//! the needs-sync predicate. + +use tauri::AppHandle; + +use crate::app_state::AppState; +use crate::managed_agents::managed_agent_avatar_url; + +use super::*; + +pub(crate) struct ProfileReconcileData { + pub(crate) private_key_nsec: String, + pub(crate) name: String, + pub(crate) relay_url: String, + /// Expected avatar URL for the published profile. `None` for legacy records + /// that predate the `avatar_url` field — these will be backfilled from the + /// relay's existing kind:0 profile on first reconciliation. + pub(crate) avatar_url: Option, + pub(crate) auth_tag: Option, + /// The agent's pubkey (hex). Needed to update the persisted record during + /// avatar backfill migration. + pub(crate) pubkey: String, + /// The agent's command (e.g. "goose"). Used as fallback when no profile + /// exists on the relay during avatar backfill. + pub(crate) agent_command: String, + /// Persona ID if this agent was created from a persona. Used during avatar + /// backfill to recover the correct avatar from the persona record when the + /// relay profile has been corrupted. + pub(crate) persona_id: Option, +} + +/// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no +/// stored `avatar_url`). +/// +/// Priority: the persona's avatar wins, because the old reconciliation code +/// could have overwritten the relay's kind:0 `picture` with the command default +/// — making the relay an unreliable source for persona-backed agents. Only fall +/// back to the relay's `picture`, then the command icon, for agents with no +/// persona avatar to recover from. +pub(super) fn resolve_legacy_avatar( + persona_avatar: Option, + relay_picture: Option, + agent_command: &str, +) -> String { + persona_avatar + .or(relay_picture) + .or_else(|| managed_agent_avatar_url(agent_command)) + .unwrap_or_default() +} + +/// Reconcile an agent's kind:0 profile on the relay. +/// +/// Queries the relay for the agent's existing profile and re-publishes if missing +/// or stale (display_name or picture mismatch). This is fire-and-forget — errors +/// are returned to the caller for logging but never block agent startup. +/// +/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function +/// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar +/// over the relay's `picture`, since the old code may have corrupted the relay +/// profile — and persists the updated record. After backfill, normal +/// reconciliation proceeds. +/// +/// Query and publish target the relay returned by `effective_agent_relay_url` +/// for every agent regardless of backend: an explicit per-agent `relay_url` +/// wins, and a blank one falls back to the active workspace relay. This keeps +/// reconciliation following the session's relay for never-pinned agents while +/// honoring a deliberate pin wherever it points. +pub(crate) async fn reconcile_agent_profile( + state: &AppState, + app: &AppHandle, + agent_pubkey: &str, + data: &ProfileReconcileData, +) -> Result<(), String> { + use crate::relay::{query_agent_profile, sync_managed_agent_profile}; + + // An explicit per-agent relay wins; an empty one falls back to the active + // workspace relay. Resolved once and used for both the read and write-back. + let relay_url = crate::relay::effective_agent_relay_url( + &data.relay_url, + &relay_ws_url_with_override(state), + ); + + // Query the relay for the agent's existing kind:0 profile. + let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?; + + // Resolve the expected avatar — backfilling for legacy records that have no + // stored avatar_url yet. + let expected_avatar = match data.avatar_url.as_deref() { + Some(url) => url.to_string(), + None => { + // Legacy record: the relay profile may have been corrupted by the + // old reconciliation code (it overwrote the persona avatar with the + // command default), so the persona record is the authoritative source. + let persona_avatar = data.persona_id.as_ref().and_then(|pid| { + load_personas(app) + .ok()? + .into_iter() + .find(|p| p.id == *pid)? + .avatar_url + }); + + let backfilled = resolve_legacy_avatar( + persona_avatar, + existing.as_ref().and_then(|info| info.picture.clone()), + &data.agent_command, + ); + + // Persist the backfilled avatar so this migration only runs once. + if !backfilled.is_empty() { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) { + record.avatar_url = Some(backfilled.clone()); + save_managed_agents(app, &records)?; + } + } + + backfilled + } + }; + + let expected_avatar = if expected_avatar.is_empty() { + None + } else { + Some(expected_avatar) + }; + + if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) { + return Ok(()); + } + + let agent_keys = Keys::parse(&data.private_key_nsec) + .map_err(|e| format!("failed to parse agent keys: {e}"))?; + + sync_managed_agent_profile( + state, + &relay_url, + &agent_keys, + &data.name, + expected_avatar.as_deref(), + data.auth_tag.as_deref(), + ) + .await +} + +/// Decide whether a published profile is missing or stale relative to the +/// expected name and avatar. A missing profile always needs sync; a present +/// one is stale when either the display name or picture diverges. +pub(super) fn profile_needs_sync( + existing: Option<&crate::relay::AgentProfileInfo>, + expected_name: &str, + expected_avatar: Option<&str>, +) -> bool { + match existing { + None => true, + Some(info) => { + let name_matches = info.display_name.as_deref() == Some(expected_name); + let picture_matches = info.picture.as_deref() == expected_avatar; + !name_matches || !picture_matches + } + } +} + +// Async so the blocking body (disk reads/writes + process termination) runs off +// the main UI thread via spawn_blocking. State is re-derived from the owned +// AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send). diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index f141c6ae50..469095f3c7 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -1,4 +1,155 @@ use super::*; +use crate::managed_agents::AgentDefinition; + +fn bare_agent_record( + persona_id: Option<&str>, + model: Option<&str>, + provider: Option<&str>, +) -> ManagedAgentRecord { + use crate::managed_agents::{BackendKind, RespondTo}; + use std::collections::BTreeMap; + ManagedAgentRecord { + pubkey: "agent".to_string(), + name: "Agent".to_string(), + persona_id: persona_id.map(str::to_string), + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: model.map(str::to_string), + provider: provider.map(str::to_string), + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + relay_mesh: None, + auto_restart_on_config_change: false, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + } +} +fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { + use std::collections::BTreeMap; + AgentDefinition { + id: id.to_string(), + display_name: "Test Persona".to_string(), + avatar_url: None, + system_prompt: "".to_string(), + runtime: None, + model: model.map(str::to_string), + provider: provider.map(str::to_string), + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + } +} + +/// Deploy-path regression for Fix 1 of Thufir pass-2: a persona-linked +/// provider agent with a stale record snapshot must use the live persona +/// model/provider in the deploy payload, not the stale record values. +/// +/// Scenario: agent was created with persona at model="old-model"/provider="old-prov". +/// The persona was subsequently updated to "new-model"/"new-prov" but the record +/// was NOT re-snapshotted (provider start skips re-snapshot; local spawn does it). +/// The deploy resolver must use the current persona values. +/// +/// Fails against `resolve_effective_model_provider` (record-first precedence), +/// which would return "old-model"/"old-prov" from the stale record. +#[test] +fn deploy_resolver_uses_live_persona_over_stale_record_snapshot() { + // Record holds the stale snapshot (created when persona had old values). + let record = bare_agent_record(Some("p1"), Some("old-model"), Some("old-prov")); + // Live persona has been updated since the record was snapshotted. + let personas = vec![persona_record("p1", Some("new-model"), Some("new-prov"))]; + let global = crate::managed_agents::GlobalAgentConfig::default(); + + let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); + + assert_eq!( + model, + Some("new-model"), + "deploy must use live persona model, not stale record snapshot" + ); + assert_eq!( + provider, + Some("new-prov"), + "deploy must use live persona provider, not stale record snapshot" + ); +} + +/// Deploy resolver falls back to record when persona has no model/provider +/// (persona without structured model — fallback to record snapshot). +#[test] +fn deploy_resolver_falls_back_to_record_when_persona_has_none() { + let record = bare_agent_record(Some("p1"), Some("record-model"), Some("record-prov")); + // Persona exists but has no model/provider. + let personas = vec![persona_record("p1", None, None)]; + let global = crate::managed_agents::GlobalAgentConfig::default(); + + let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); + + assert_eq!(model, Some("record-model")); + assert_eq!(provider, Some("record-prov")); +} + +/// Deploy resolver falls back to global when both persona and record have none. +#[test] +fn deploy_resolver_falls_back_to_global_when_persona_and_record_have_none() { + let record = bare_agent_record(Some("p1"), None, None); + let personas = vec![persona_record("p1", None, None)]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-prov".to_string()), + ..Default::default() + }; + + let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); + + assert_eq!(model, Some("global-model")); + assert_eq!(provider, Some("global-prov")); +} #[test] fn normalize_relay_mesh_rejects_empty_model_ref() { @@ -8,7 +159,7 @@ fn normalize_relay_mesh_rejects_empty_model_ref() { assert_eq!( normalize_relay_mesh(Some(&config), &BackendKind::Local).unwrap_err(), - "relay mesh modelRef is required" + "Buzz shared compute model is required" ); } @@ -24,7 +175,7 @@ fn normalize_relay_mesh_rejects_non_local_backend() { assert_eq!( normalize_relay_mesh(Some(&config), &backend).unwrap_err(), - "relay mesh agents must use the local backend" + "Buzz shared compute agents must use the local backend" ); } @@ -82,6 +233,11 @@ fn profile_needs_sync_when_missing() { assert!(profile_needs_sync(None, "Duncan", Some("https://x/a.png"))); } +#[test] +fn profile_needs_sync_when_missing_even_without_expected_avatar() { + assert!(profile_needs_sync(None, "Duncan", None)); +} + #[test] fn profile_needs_sync_when_name_diverges() { let existing = profile(Some("Stilgar"), Some("https://x/a.png")); @@ -169,3 +325,52 @@ fn legacy_avatar_empty_when_nothing_resolves() { assert!(resolved.is_empty()); } + +// ── Provider deploy payload completeness ───────────────────────────────────── + +/// Regression (PR #1667 review, Thufir): the provider deploy payload must +/// carry every behavioral field the local spawn path applies — a field +/// missing here silently strips it from provider-backed agents. +#[test] +fn deploy_payload_carries_the_full_behavioral_quad() { + let allow = "a".repeat(64); + let record: ManagedAgentRecord = serde_json::from_str(&format!( + r#"{{ + "pubkey": "abcd1234", + "name": "test-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "parallelism": 4, + "respond_to": "allowlist", + "respond_to_allowlist": ["{allow}"], + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }}"# + )) + .expect("sample record"); + + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + Some("gpt-x".to_string()), + Some("openai".to_string()), + std::collections::BTreeMap::new(), + ); + + assert_eq!(payload["parallelism"], 4); + assert_eq!(payload["respond_to"], "allowlist"); + assert_eq!(payload["respond_to_allowlist"][0], "a".repeat(64)); + assert_eq!(payload["model"], "gpt-x"); + assert_eq!(payload["provider"], "openai"); + assert_eq!(payload["relay_url"], "wss://relay.example"); +} diff --git a/desktop/src-tauri/src/commands/channel_templates.rs b/desktop/src-tauri/src/commands/channel_templates.rs index c4e8dfca1a..5d88e162c9 100644 --- a/desktop/src-tauri/src/commands/channel_templates.rs +++ b/desktop/src-tauri/src/commands/channel_templates.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager}; use uuid::Uuid; use crate::{ @@ -44,142 +44,157 @@ fn validate_visibility(value: &str) -> Result<(), String> { } #[tauri::command] -pub fn list_channel_templates( - app: AppHandle, - state: State<'_, AppState>, -) -> Result, String> { - let _store_guard = state - .channel_templates_store_lock - .lock() - .map_err(|error| error.to_string())?; - load_channel_templates(&app) +pub async fn list_channel_templates(app: AppHandle) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + load_channel_templates(&app) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn create_channel_template( +pub async fn create_channel_template( input: CreateChannelTemplateRequest, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let name = trim_required(&input.name, "Template name")?; - let description = trim_optional(input.description); - let canvas_template = trim_optional(input.canvas_template); - let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); - let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); - validate_channel_type(&channel_type)?; - validate_visibility(&visibility)?; - let now = now_iso(); + tokio::task::spawn_blocking(move || { + let name = trim_required(&input.name, "Template name")?; + let description = trim_optional(input.description); + let canvas_template = trim_optional(input.canvas_template); + let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); + let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); + validate_channel_type(&channel_type)?; + validate_visibility(&visibility)?; + let now = now_iso(); - let _store_guard = state - .channel_templates_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut templates = load_channel_templates(&app)?; + let state = app.state::(); + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; - let template = ChannelTemplateRecord { - id: Uuid::new_v4().to_string(), - name, - description, - channel_type, - visibility, - canvas_template, - agents: input.agents, - is_builtin: false, - created_at: now.clone(), - updated_at: now, - }; + let template = ChannelTemplateRecord { + id: Uuid::new_v4().to_string(), + name, + description, + channel_type, + visibility, + canvas_template, + agents: input.agents, + is_builtin: false, + created_at: now.clone(), + updated_at: now, + }; - templates.push(template.clone()); - save_channel_templates(&app, &templates)?; - Ok(template) + templates.push(template.clone()); + save_channel_templates(&app, &templates)?; + Ok(template) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn update_channel_template( +pub async fn update_channel_template( input: UpdateChannelTemplateRequest, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let name = trim_required(&input.name, "Template name")?; - let description = trim_optional(input.description); - let canvas_template = trim_optional(input.canvas_template); - let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); - let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); - validate_channel_type(&channel_type)?; - validate_visibility(&visibility)?; + tokio::task::spawn_blocking(move || { + let name = trim_required(&input.name, "Template name")?; + let description = trim_optional(input.description); + let canvas_template = trim_optional(input.canvas_template); + let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); + let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); + validate_channel_type(&channel_type)?; + validate_visibility(&visibility)?; - let _store_guard = state - .channel_templates_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut templates = load_channel_templates(&app)?; - let template = templates - .iter_mut() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("template {} not found", input.id))?; + let state = app.state::(); + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; + let template = templates + .iter_mut() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("template {} not found", input.id))?; - template.name = name; - template.description = description; - template.channel_type = channel_type; - template.visibility = visibility; - template.canvas_template = canvas_template; - template.agents = input.agents; - template.updated_at = now_iso(); + template.name = name; + template.description = description; + template.channel_type = channel_type; + template.visibility = visibility; + template.canvas_template = canvas_template; + template.agents = input.agents; + template.updated_at = now_iso(); - let updated = template.clone(); - save_channel_templates(&app, &templates)?; - Ok(updated) + let updated = template.clone(); + save_channel_templates(&app, &templates)?; + Ok(updated) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn delete_channel_template( - id: String, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { - let _store_guard = state - .channel_templates_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut templates = load_channel_templates(&app)?; - let template = templates - .iter() - .find(|record| record.id == id) - .ok_or_else(|| format!("template {id} not found"))?; - validate_channel_template_deletion(template)?; - templates.retain(|record| record.id != id); - save_channel_templates(&app, &templates) +pub async fn delete_channel_template(id: String, app: AppHandle) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; + let template = templates + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("template {id} not found"))?; + validate_channel_template_deletion(template)?; + templates.retain(|record| record.id != id); + save_channel_templates(&app, &templates) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn duplicate_channel_template( +pub async fn duplicate_channel_template( id: String, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let now = now_iso(); + tokio::task::spawn_blocking(move || { + let now = now_iso(); - let _store_guard = state - .channel_templates_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut templates = load_channel_templates(&app)?; - let source = templates - .iter() - .find(|record| record.id == id) - .ok_or_else(|| format!("template {id} not found"))? - .clone(); + let state = app.state::(); + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; + let source = templates + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("template {id} not found"))? + .clone(); - let duplicate = ChannelTemplateRecord { - id: Uuid::new_v4().to_string(), - name: format!("{} (Copy)", source.name), - is_builtin: false, - created_at: now.clone(), - updated_at: now, - ..source - }; + let duplicate = ChannelTemplateRecord { + id: Uuid::new_v4().to_string(), + name: format!("{} (Copy)", source.name), + is_builtin: false, + created_at: now.clone(), + updated_at: now, + ..source + }; - templates.push(duplicate.clone()); - save_channel_templates(&app, &templates)?; - Ok(duplicate) + templates.push(duplicate.clone()); + save_channel_templates(&app, &templates)?; + Ok(duplicate) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59ab3b25a9..4f86e55504 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -5,11 +5,56 @@ use crate::{ events, models::{ChannelDetailInfo, ChannelInfo, ChannelMembersResponse}, nostr_convert, - relay::{query_relay, submit_event}, + relay::{query_relay, submit_event, submit_event_with_keys}, }; // ── Reads (pure-nostr via /query) ──────────────────────────────────────────── +const DIRECTORY_PAGE_SIZE: usize = 500; + +fn advance_directory_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) { + let last = page + .last() + .expect("a full relay page always has a last event"); + filter["until"] = serde_json::json!(last.created_at.as_secs()); + filter["before_id"] = serde_json::json!(last.id.to_hex()); +} + +/// Fetch every page for a historical relay filter using the relay's composite +/// `(until, before_id)` cursor. A timestamp-only cursor can skip rows when more +/// than one page of events shares the same second. +async fn query_relay_all( + state: &AppState, + mut filter: serde_json::Value, +) -> Result, String> { + filter["limit"] = serde_json::json!(DIRECTORY_PAGE_SIZE); + let mut all = Vec::new(); + + loop { + let page = query_relay(state, &[filter.clone()]).await?; + let done = page.len() < DIRECTORY_PAGE_SIZE; + + if !done { + advance_directory_cursor(&mut filter, &page); + } + + all.extend(page); + if done { + return Ok(all); + } + } +} + +/// Whether an open channel not yet in the real member set should still be +/// classified `is_member=true` via the pending-owner overlay. Pulled out of +/// `get_channels`'s open-channel branch so the exact `(d_tag, my_pubkey, +/// overlay) -> is_member` decision — including the identity binding that +/// keeps one identity's pending entry from covering another's — is directly +/// unit-testable without going through the async relay-backed command. +fn classify_pending_owner(state: &AppState, my_pubkey: &str, d_tag: Option<&str>) -> bool { + d_tag.is_some_and(|d| state.is_pending_owned_channel(my_pubkey, d)) +} + #[tauri::command] pub async fn get_channels(state: State<'_, AppState>) -> Result, String> { let _profile_start = std::time::Instant::now(); @@ -20,26 +65,11 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result // Step 1: find all kind:39002 (members) events that mention me, then // pull the channel ids out of their `d` tags. - let member_events = { - let mut all = Vec::new(); - let mut until: Option = None; - loop { - let mut f = serde_json::json!({"kinds": [39002], "#p": [&my_pubkey], "limit": 500}); - if let Some(u) = until { - f["until"] = serde_json::json!(u); - } - let page = query_relay(&state, &[f]).await?; - let done = page.len() < 500; - if let Some(t) = page.iter().map(|e| e.created_at.as_secs()).min() { - until = Some(t.saturating_sub(1)); - } - all.extend(page); - if done { - break; - } - } - all - }; + let member_events = query_relay_all( + &state, + serde_json::json!({"kinds": [39002], "#p": [&my_pubkey]}), + ) + .await?; #[cfg(debug_assertions)] let t_members = _profile_start.elapsed(); @@ -60,6 +90,15 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result channel_ids.sort(); channel_ids.dedup(); + // The real kind:39002 membership has now resolved for these channels — + // drop them from the pending-owner overlay (see `AppState::pending_owned_channels`) + // so a channel this identity created no longer speaks through the overlay + // once genuine membership is observable, and a later leave correctly + // flips it back to `is_member=false`. + for id in &channel_ids { + state.clear_pending_owned_channel(&my_pubkey, id); + } + // Step 2: fetch channel metadata events (kind:39000) for member channels. // kind:39000 is addressable: exactly one event per `d` tag, so a limit // equal to the number of ids is both necessary and sufficient. Without @@ -85,14 +124,7 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result // Step 3: fetch ALL open channel metadata so the channel browser can show // discoverable channels the user hasn't joined yet. The relay's access // control allows reading kind:39000 for open channels regardless of membership. - let open_meta_events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [39000], - "limit": 5000, - })], - ) - .await?; + let open_meta_events = query_relay_all(&state, serde_json::json!({"kinds": [39000]})).await?; #[cfg(debug_assertions)] let t_open_meta = _profile_start.elapsed(); @@ -133,7 +165,18 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result continue; } } - if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(false)) { + // The overlay (`AppState::pending_owned_channels`) marks channels this + // identity just created via `create_channel` whose kind:39002 owner + // membership hasn't propagated yet (#1761) — a fresh channel has no + // member event and would otherwise fall through to `is_member=false` + // here, disabling the owner's own composer until that snapshot lands. + // The overlay can only be populated by this process's own + // `create_channel` call (never by relay data) and is keyed by + // `(my_pubkey, d_tag)`, so it adds no trust-boundary risk and can + // never speak for a channel a different identity created; `channel_ids` + // above clears it once real membership is observed for `my_pubkey`. + let is_pending_owner = classify_pending_owner(&state, &my_pubkey, d_tag.as_deref()); + if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(is_pending_owner)) { channels.push(info); } } @@ -430,10 +473,26 @@ pub async fn create_channel( description.as_deref(), ttl_seconds, )?; - submit_event(builder, &state).await?; + + // Capture the signing identity before submission so the pending-owner + // mark below is bound to whoever actually signed this create — not + // whoever `state.keys` holds once the network round-trip completes. An + // in-process identity swap while the request is in flight must not be + // able to retarget the mark onto the new identity. + let creator_keys = state.signing_keys()?; + let creator_pubkey = creator_keys.public_key().to_hex(); + submit_event_with_keys(builder, &state, &creator_keys, None).await?; + + // Mark this channel pending-owner: we just created it, so we know we're + // the owner, but the relay's kind:39002 membership entry (#1761) is + // provisioned asynchronously. `get_channels` consults this overlay to + // classify us as `is_member=true` until that entry is observable. Bound + // to the identity that signed the create above, so an in-process + // identity swap can neither inherit nor retarget this entry. + let channel_uuid_string = channel_uuid.to_string(); + state.mark_pending_owned_channel(&creator_pubkey, &channel_uuid_string); // Re-fetch the canonical metadata event to return ChannelInfo. - let channel_uuid_string = channel_uuid.to_string(); let events = query_relay( &state, &[serde_json::json!({ diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index e902b5edfe..e77297fa3a 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -2,10 +2,14 @@ // channels.rs under the per-file line cap. use super::*; -use nostr::{EventBuilder, Keys, Kind, Tag}; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; /// Build a signed event for testing with the given kind, content, and tags. fn ev(kind: u16, content: &str, tags: Vec>) -> nostr::Event { + ev_at(kind, content, tags, Timestamp::now()) +} + +fn ev_at(kind: u16, content: &str, tags: Vec>, created_at: Timestamp) -> nostr::Event { let keys = Keys::generate(); let parsed: Vec = tags .into_iter() @@ -13,6 +17,7 @@ fn ev(kind: u16, content: &str, tags: Vec>) -> nostr::Event { .collect(); EventBuilder::new(Kind::from_u16(kind), content) .tags(parsed) + .custom_created_at(created_at) .sign_with_keys(&keys) .expect("sign") } @@ -22,6 +27,18 @@ const PK_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa const PK_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const PK_C: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +#[test] +fn directory_cursor_keeps_same_second_tiebreaker() { + let timestamp = Timestamp::from(1_700_000_000); + let event = ev_at(39000, "{}", vec![], timestamp); + let mut filter = serde_json::json!({"kinds": [39000], "limit": DIRECTORY_PAGE_SIZE}); + + advance_directory_cursor(&mut filter, std::slice::from_ref(&event)); + + assert_eq!(filter["until"], serde_json::json!(timestamp.as_secs())); + assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex())); +} + #[test] fn counts_unique_p_tags_per_channel() { let e1 = ev( @@ -92,3 +109,134 @@ fn empty_input_yields_empty_map() { let membership = collect_members_by_channel(&[]); assert!(membership.is_empty()); } + +#[test] +fn pending_overlay_marks_relay_signed_channel_as_member() { + // The real production shape: kind:39000 is relay-signed (#1761), so the + // event's author is never the creator. A fresh channel's owner is + // classified via the pending-owner overlay (populated by `create_channel` + // in this same process), not via the event's pubkey. + let relay_keys = Keys::generate(); + let e = EventBuilder::new(Kind::from_u16(39000), "") + .tags(vec![ + Tag::parse(["d", "chan-1"]).expect("parse tag"), + Tag::parse(["name", "n"]).expect("parse tag"), + ]) + .sign_with_keys(&relay_keys) + .expect("sign"); + + let state = crate::app_state::build_app_state(); + state.mark_pending_owned_channel(PK_A, "chan-1"); + + let info = crate::nostr_convert::channel_info_from_event( + &e, + None, + Some(classify_pending_owner(&state, PK_A, Some("chan-1"))), + ) + .unwrap(); + assert!(info.is_member); +} + +#[test] +fn pending_overlay_leaves_unrelated_channel_as_non_member() { + // A relay-signed channel this identity never created (not in the + // overlay) must stay `is_member=false` — no over-broad match. + let relay_keys = Keys::generate(); + let e = EventBuilder::new(Kind::from_u16(39000), "") + .tags(vec![ + Tag::parse(["d", "chan-1"]).expect("parse tag"), + Tag::parse(["name", "n"]).expect("parse tag"), + ]) + .sign_with_keys(&relay_keys) + .expect("sign"); + + let state = crate::app_state::build_app_state(); + // Overlay has a different channel pending for the same identity, not + // this one. + state.mark_pending_owned_channel(PK_A, "chan-other"); + + let info = crate::nostr_convert::channel_info_from_event( + &e, + None, + Some(classify_pending_owner(&state, PK_A, Some("chan-1"))), + ) + .unwrap(); + assert!(!info.is_member); +} + +#[test] +fn pending_overlay_cleared_once_real_membership_observed() { + // Once the real kind:39002 lands (modeled here as `get_channels`'s + // cleanup step: clearing every channel id it just found real membership + // for), the overlay must stop speaking for that channel — otherwise a + // later leave would never flip `is_member` back to false. + let state = crate::app_state::build_app_state(); + state.mark_pending_owned_channel(PK_A, "chan-1"); + assert!(state.is_pending_owned_channel(PK_A, "chan-1")); + + // Mirrors the `for id in &channel_ids { state.clear_pending_owned_channel(&my_pubkey, id) }` + // step in `get_channels` once "chan-1" appears in PK_A's real member set. + state.clear_pending_owned_channel(PK_A, "chan-1"); + assert!(!state.is_pending_owned_channel(PK_A, "chan-1")); +} + +#[test] +fn pending_overlay_does_not_leak_across_identity_swap() { + // Regression for the IMPORTANT Thufir flagged on the bare-channel-id + // overlay: `import_identity`/workspace-apply can replace `state.keys` in + // process without clearing the overlay. Identity A creates a channel and + // is recorded pending-owner; if the process then switches to identity B + // (same `AppState`, same channel id), B must NOT inherit A's entry. + let state = crate::app_state::build_app_state(); + state.mark_pending_owned_channel(PK_A, "chan-1"); + + assert!(state.is_pending_owned_channel(PK_A, "chan-1")); + assert!(!state.is_pending_owned_channel(PK_B, "chan-1")); +} + +#[test] +fn classify_pending_owner_matches_only_the_owning_identity() { + // Exercises the exact branch-level decision `get_channels`'s open-channel + // fallthrough makes, not just the underlying `AppState` helpers in + // isolation. + let state = crate::app_state::build_app_state(); + state.mark_pending_owned_channel(PK_A, "chan-1"); + + assert!(classify_pending_owner(&state, PK_A, Some("chan-1"))); + // Different identity, same channel id: must not match. + assert!(!classify_pending_owner(&state, PK_B, Some("chan-1"))); + // Same identity, different channel id: must not match. + assert!(!classify_pending_owner(&state, PK_A, Some("chan-other"))); + // No `d` tag on the event at all: must not match. + assert!(!classify_pending_owner(&state, PK_A, None)); +} + +#[test] +fn pending_owner_mark_uses_signer_captured_before_identity_swap() { + // Regression for the write-side IMPORTANT Thufir flagged in pass 3: + // `create_channel` used to re-read `state.keys` *after* the submit + // await, so an identity swap that lands during the in-flight request + // could mark the overlay under the new identity instead of the one that + // actually signed the create. The fix captures the signer up front and + // marks with that captured identity, so a swap that happens afterward + // (i.e. during what would be the submit await) can't retarget the mark. + let state = crate::app_state::build_app_state(); + + // Mirrors `create_channel`'s new capture-before-submit step: read the + // signer identity once, before anything that could race with a swap. + let creator_keys = state.signing_keys().expect("signable"); + let creator_pubkey = creator_keys.public_key().to_hex(); + + // Simulate an in-process identity swap landing during the (here, + // implicit) submit await — e.g. `import_identity` replacing + // `state.keys` while the create request is in flight. + *state.keys.lock().expect("lock keys") = Keys::generate(); + + // The mark must use the captured signer, not whatever `state.keys` + // holds now. + state.mark_pending_owned_channel(&creator_pubkey, "chan-1"); + + assert!(state.is_pending_owned_channel(&creator_pubkey, "chan-1")); + let post_swap_pubkey = state.keys.lock().expect("lock keys").public_key().to_hex(); + assert!(!state.is_pending_owned_channel(&post_swap_pubkey, "chan-1")); +} diff --git a/desktop/src-tauri/src/commands/export_util.rs b/desktop/src-tauri/src/commands/export_util.rs index 8ebe4333bc..806f58d739 100644 --- a/desktop/src-tauri/src/commands/export_util.rs +++ b/desktop/src-tauri/src/commands/export_util.rs @@ -1,17 +1,6 @@ use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; -/// Show a save-file dialog for a JSON export and write `data` to the chosen -/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the -/// user cancelled the dialog. -pub async fn save_json_with_dialog( - app: &AppHandle, - suggested_filename: &str, - data: &[u8], -) -> Result { - save_bytes_with_dialog(app, suggested_filename, "JSON", &["json"], data).await -} - /// Show a save-file dialog with a custom filter and write `data` to the chosen /// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the /// user cancelled the dialog. diff --git a/desktop/src-tauri/src/commands/global_agent_config.rs b/desktop/src-tauri/src/commands/global_agent_config.rs new file mode 100644 index 0000000000..37a1be5226 --- /dev/null +++ b/desktop/src-tauri/src/commands/global_agent_config.rs @@ -0,0 +1,523 @@ +//! Tauri commands for global agent configuration defaults. +//! +//! `get_global_agent_config` / `set_global_agent_config` — simple load/save +//! around the `global_config` module with the standard save-time validation. +//! +//! `set_global_agent_config` additionally auto-restarts any running local agent +//! whose effective env changes under the new global config — including agents +//! that were in setup-listener mode (`NotReady`) but become `Ready`, and agents +//! already running whose provider/model/env vars change. This is the only +//! honest way to deliver new env vars to a running process — the env is baked +//! at spawn time and cannot be mutated in place. + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, + load_global_agent_config, load_managed_agents, load_personas, process_is_running, + record_agent_command, resolve_effective_agent_env, save_global_agent_config, + save_managed_agents, stop_managed_agent_process, sync_managed_agent_processes, + validate_global_config, AgentReadiness, BackendKind, GlobalAgentConfig, + }, +}; + +/// Result returned by `set_global_agent_config`. +/// +/// Carries the canonical saved config together with restart counts. Use +/// `restarted_count` for "Restarted N agent(s)." feedback and +/// `failed_restart_count` to surface partial failures ("M failed to restart"). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobalAgentConfigSaveResult { + /// The persisted global config (after strip-on-write). + pub config: GlobalAgentConfig, + /// Number of local agents successfully stopped and restarted. + pub restarted_count: u32, + /// Number of agents whose stop succeeded but respawn failed. + pub failed_restart_count: u32, +} + +/// Read the current global agent configuration. +/// +/// Returns the default (empty) config if `global-agent-config.json` has not +/// been written yet. +#[tauri::command] +pub fn get_global_agent_config(app: AppHandle) -> Result { + load_global_agent_config(&app) +} + +/// Validate and persist a new global agent configuration, then auto-restart +/// any running local agent whose effective env changes under the new config +/// (including setup-listener agents whose readiness flips to `Ready`). +/// +/// Strips empty env values before writing (empty = "inherit" semantics), then +/// applies standard validation: POSIX key shape, reserved-key reject, +/// derived-provider-model-key reject, NUL/size caps. +/// +/// Restart is best-effort: per-agent errors are logged to stderr and persisted +/// to `last_error` but do not fail the command. Returns the saved config and +/// the count of agents successfully restarted. +#[tauri::command] +pub async fn set_global_agent_config( + config: GlobalAgentConfig, + app: AppHandle, +) -> Result { + use tauri::Manager; + + // ── Phase 1: disk write (sync, spawn_blocking) ──────────────────────── + // + // Validate, snapshot old config, write new config, collect pre-filter + // candidate pubkeys (local backend + recorded PID + old NotReady + new + // Ready). The candidate list is a hint — eligibility is re-checked under + // lock in Phase 2 after sync_managed_agent_processes. + let app_for_write = app.clone(); + let phase1 = tokio::task::spawn_blocking(move || { + validate_global_config(&config)?; + + let old_global = load_global_agent_config(&app_for_write).unwrap_or_default(); + + save_global_agent_config(&app_for_write, &config)?; + + // Re-read from disk so the returned value reflects the strip-on-write pass. + let new_global = load_global_agent_config(&app_for_write)?; + + // Pre-filter: identify agents that look eligible before taking any locks. + // This is a hint only; definitive eligibility check happens under lock + // in Phase 2. + let (candidates, personas_snapshot) = + collect_restart_candidates(&app_for_write, &old_global, &new_global); + + Ok::<_, String>((new_global, old_global, candidates, personas_snapshot)) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + let (new_global, old_global, candidates, personas_snapshot) = phase1; + + // ── Phase 2: async restart (outside spawn_blocking) ────────────────── + // + // For each candidate: stop under the lock (re-verifying eligibility after + // sync_managed_agent_processes), then start via start_local_agent_with_preflight + // — the same path as a manual restart. This ensures owner_hex is computed + // and passed (NIP-OA auth_tag fallback), the persona is re-snapshotted, and + // last_error is persisted on failure. + // + // Errors are non-fatal; the caller always receives the saved config. + // failed_restart_count surfaces stops that succeeded but respawn failed. + let mut restarted_count: u32 = 0; + let mut failed_restart_count: u32 = 0; + if !candidates.is_empty() { + let state = app.state::(); + let owner_hex = match super::agents::workspace_owner_hex(&state) { + Ok(h) => h, + Err(e) => { + eprintln!( + "buzz-desktop: set_global_agent_config: failed to compute owner_hex for restart: {e}" + ); + return Ok(GlobalAgentConfigSaveResult { + config: new_global, + restarted_count: 0, + failed_restart_count: 0, + }); + } + }; + + for pubkey in &candidates { + let outcome = restart_local_agent_on_config_change( + &app, + pubkey, + &owner_hex, + &old_global, + &new_global, + &personas_snapshot, + ) + .await; + match outcome { + RestartOutcome::Restarted => restarted_count += 1, + RestartOutcome::FailedAfterStop => failed_restart_count += 1, + RestartOutcome::Skipped => {} + } + } + } + + Ok(GlobalAgentConfigSaveResult { + config: new_global, + restarted_count, + failed_restart_count, + }) +} + +/// Outcome of a single per-agent restart attempt in Phase 2. +#[derive(Debug)] +enum RestartOutcome { + /// Stop succeeded and the agent re-launched with the new config. + Restarted, + /// Stop succeeded but the subsequent spawn failed. + FailedAfterStop, + /// Eligibility check failed under lock — agent skipped without touching it. + Skipped, +} + +/// Collect pubkeys of local agents that should be restarted after a global +/// config change, together with the personas snapshot used for the scan. +/// +/// Pre-lock hint used by Phase 1 of `set_global_agent_config`. Eligibility is +/// re-verified under lock in Phase 2. The personas snapshot is threaded to +/// `restart_local_agent_on_config_change` so it is not reloaded per agent. +/// +/// An agent is a candidate when it is a local backend with a recorded PID, and +/// either: +/// - its readiness transitions `NotReady → Ready` (was blocked on missing +/// provider/model key, now unblocked), OR +/// - it was already `Ready`, its process is currently alive, and its effective +/// env changed (provider, model, or env var update that needs a restart to +/// take effect, since env is baked at spawn time). +fn collect_restart_candidates( + app: &AppHandle, + old_global: &GlobalAgentConfig, + new_global: &GlobalAgentConfig, +) -> (Vec, Vec) { + let records = match load_managed_agents(app) { + Ok(r) => r, + Err(e) => { + eprintln!( + "buzz-desktop: set_global_agent_config: failed to load agents for restart scan: {e}" + ); + return (Vec::new(), Vec::new()); + } + }; + let all_personas = match load_personas(app) { + Ok(p) => p, + Err(e) => { + eprintln!( + "buzz-desktop: set_global_agent_config: failed to load personas for restart scan: {e}" + ); + return (Vec::new(), Vec::new()); + } + }; + + let candidates = records + .iter() + .filter(|record| { + if record.backend != BackendKind::Local { + return false; + } + // Quick pre-check: must have a recorded PID (may still be alive). + let Some(pid) = record.runtime_pid else { + return false; + }; + let effective_cmd = record_agent_command(record, &all_personas); + let runtime_meta = known_acp_runtime(&effective_cmd); + let old_effective = + resolve_effective_agent_env(record, &all_personas, runtime_meta, old_global); + let new_effective = + resolve_effective_agent_env(record, &all_personas, runtime_meta, new_global); + let old_ready = matches!(agent_readiness(&old_effective), AgentReadiness::Ready); + let new_ready = matches!(agent_readiness(&new_effective), AgentReadiness::Ready); + // For a Ready+running agent: the process must be alive now and the + // process-env map must differ. The alive check avoids queuing a + // restart for a process that already exited between the pre-filter + // scan and Phase 2. NotReady→Ready bypasses the alive check + // because Phase 2 will stop-then-start unconditionally. + let env_changed = + old_ready && process_is_running(pid) && old_effective.env != new_effective.env; + + should_restart_on_config_change(old_ready, new_ready, env_changed) + }) + .map(|r| r.pubkey.clone()) + .collect(); + + (candidates, all_personas) +} + +/// Stop-then-start a local agent whose effective env changed under the new +/// global config. +/// +/// This is the per-agent restart step in Phase 2 of `set_global_agent_config`. +/// It mirrors the semantics of a manual agent restart: +/// +/// 1. **Stop under lock** — acquires the store lock, calls +/// `sync_managed_agent_processes`, re-verifies eligibility (local backend, +/// live process, effective env changed or readiness transition), then stops +/// the process and saves the record. The lock is released before the start +/// so `start_local_agent_with_preflight` can re-acquire it cleanly. +/// `personas_snapshot` is reused here instead of loading from disk again. +/// +/// 2. **Start via the normal preflight path** — calls +/// `start_local_agent_with_preflight`, which computes and passes `owner_hex` +/// (NIP-OA fallback for legacy records without `auth_tag`), re-snapshots the +/// persona (agent starts with current persona config), saves the updated +/// record, and retains the event for relay sync. On failure, `last_error` is +/// persisted under lock so the UI surfaces a diagnosable stopped state. +/// +/// All errors are logged to stderr. Returns `RestartOutcome::FailedAfterStop` +/// when the stop succeeded but the spawn failed — the caller surfaces this as +/// `failed_restart_count` so the UI can prompt the user to check the Agents tab. +async fn restart_local_agent_on_config_change( + app: &AppHandle, + pubkey: &str, + owner_hex: &str, + old_global: &GlobalAgentConfig, + new_global: &GlobalAgentConfig, + personas_snapshot: &[crate::managed_agents::AgentDefinition], +) -> RestartOutcome { + // ── Step 1: stop under lock, re-verifying eligibility ───────────────── + let app_for_stop = app.clone(); + let pubkey_owned = pubkey.to_string(); + let old_global_clone = old_global.clone(); + let new_global_clone = new_global.clone(); + let personas_owned = personas_snapshot.to_vec(); + + let stop_result = tokio::task::spawn_blocking(move || { + use tauri::Manager; + let state = app_for_stop.state::(); + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| format!("failed to acquire store lock: {e}"))?; + + let mut records = load_managed_agents(&app_for_stop)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| format!("failed to acquire runtimes lock: {e}"))?; + + // Sync process state so PID liveness reflects current reality. + let (sync_changed, _) = sync_managed_agent_processes( + &mut records, + &mut runtimes, + ¤t_instance_id(&app_for_stop), + ); + if sync_changed { + save_managed_agents(&app_for_stop, &records)?; + } + + // Re-check eligibility under lock with current record state. + let record = records + .iter() + .find(|r| r.pubkey == pubkey_owned) + .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; + + if record.backend != BackendKind::Local { + return Err(format!("agent {pubkey_owned} is no longer a local agent")); + } + let Some(pid) = record.runtime_pid else { + return Err(format!( + "agent {pubkey_owned} no longer has a live process after sync" + )); + }; + if !process_is_running(pid) { + return Err(format!( + "agent {pubkey_owned} process {pid} is no longer running" + )); + } + + // Re-check the eligibility predicate under lock: + // (old NotReady && new Ready) OR (old Ready && env changed) + // TODO: busy/mid-turn deferral would slot in here + // + // Reuse personas_snapshot from Phase 1 — avoids loading personas again + // per agent when the save-command personas haven't changed. + let effective_cmd = record_agent_command(record, &personas_owned); + let runtime_meta = known_acp_runtime(&effective_cmd); + let old_effective = + resolve_effective_agent_env(record, &personas_owned, runtime_meta, &old_global_clone); + let new_effective = + resolve_effective_agent_env(record, &personas_owned, runtime_meta, &new_global_clone); + let old_ready = matches!(agent_readiness(&old_effective), AgentReadiness::Ready); + let new_ready = matches!(agent_readiness(&new_effective), AgentReadiness::Ready); + // Under lock, the alive check was already done above via process_is_running. + let env_changed = old_ready && old_effective.env != new_effective.env; + if !should_restart_on_config_change(old_ready, new_ready, env_changed) { + return Err(format!( + "agent {pubkey_owned} restart condition no longer valid under lock" + )); + } + + // Stop the process. + let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; + stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; + save_managed_agents(&app_for_stop, &records)?; + + Ok(()) + }) + .await; + + let stopped = match stop_result { + Ok(Ok(())) => true, + Ok(Err(e)) => { + eprintln!("buzz-desktop: set_global_agent_config: skipping restart of {pubkey}: {e}"); + false + } + Err(e) => { + eprintln!( + "buzz-desktop: set_global_agent_config: spawn_blocking failed for stop of {pubkey}: {e}" + ); + false + } + }; + + if !stopped { + return RestartOutcome::Skipped; + } + + // ── Step 2: start via the normal preflight path ──────────────────────── + // + // start_local_agent_with_preflight handles: re-acquiring the store lock, + // persona re-snapshot (agent starts with current persona config), passing + // owner_hex (NIP-OA auth_tag fallback for legacy records), saving the + // updated record, and retaining the event for relay sync. + { + use tauri::Manager; + let state = app.state::(); + match super::agents::start_local_agent_with_preflight(app, &state, pubkey, owner_hex, false) + .await + { + Ok(_) => { + eprintln!( + "buzz-desktop: set_global_agent_config: restarted agent {pubkey} with updated config" + ); + RestartOutcome::Restarted + } + Err(e) => { + eprintln!( + "buzz-desktop: set_global_agent_config: failed to start {pubkey} after restart: {e}" + ); + // Persist last_error so the UI surfaces a diagnosable stopped state. + if let Err(save_err) = persist_last_error(app, pubkey, &e) { + eprintln!( + "buzz-desktop: set_global_agent_config: failed to persist last_error for {pubkey}: {save_err}" + ); + } + RestartOutcome::FailedAfterStop + } + } + } +} + +/// Persist a `last_error` on the agent record under the store lock. +/// +/// Best-effort: called only after a failed restart to leave the record +/// in a diagnosable state rather than a silent "stopped with no error" state. +fn persist_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result<(), String> { + use tauri::Manager; + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| format!("failed to acquire store lock: {e}"))?; + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + record.last_error = Some(error.to_string()); + record.updated_at = crate::util::now_iso(); + save_managed_agents(app, &records) +} + +/// Pure predicate: should an agent be restarted given resolved readiness and +/// effective-env snapshots? +/// +/// Extracted so the restart decision logic can be unit-tested without an +/// `AppHandle` or `EffectiveAgentEnv`. Both `collect_restart_candidates` and +/// the under-lock eligibility check in `restart_local_agent_on_config_change` +/// delegate to this predicate. +/// +/// Conditions: +/// - `NotReady → Ready`: blocked on missing key, now unblocked. +/// - `Ready + env changed`: running with stale env; env is baked at spawn time. +/// Also covers `Ready → NotReady` when the env changed (key removed). +/// +/// **Readiness invariant (T,F,F):** For `buzz-agent` and `goose`, readiness is +/// derived purely from `EffectiveAgentEnv` — it cannot flip without an env delta. +/// For `claude`/`codex`, `cli_login_requirements` queries runtime auth state +/// (e.g. `claude auth status`), so readiness CAN flip Ready→NotReady without +/// an env change. In that case combo (T,F,F) evaluates to `false` — the running +/// agent is NOT restarted. This is intentional: the env is unchanged, and a +/// restart would not repair the missing auth token. If the binary disappears, +/// the process would already be dead and the PID alive-check in the candidate +/// scan would have excluded it. +fn should_restart_on_config_change(old_ready: bool, new_ready: bool, env_changed: bool) -> bool { + (!old_ready && new_ready) || (old_ready && env_changed) +} + +#[cfg(test)] +mod tests { + use super::should_restart_on_config_change; + + /// Running agent (Ready) whose effective env changed → restart candidate. + #[test] + fn env_changed_running_agent_is_candidate() { + // old_ready=true, new_ready=true, env_changed=true + assert!( + should_restart_on_config_change(true, true, true), + "running agent with changed env must be restarted" + ); + } + + /// Running agent (Ready) whose effective env did NOT change → not a candidate. + #[test] + fn unchanged_running_agent_is_not_candidate() { + // old_ready=true, new_ready=true, env_changed=false + assert!( + !should_restart_on_config_change(true, true, false), + "running agent with identical env must NOT be restarted" + ); + } + + /// NotReady → Ready transition is admitted regardless of env diff. + #[test] + fn not_ready_to_ready_is_candidate() { + // old_ready=false, new_ready=true, env_changed=false (env_changed irrelevant) + assert!( + should_restart_on_config_change(false, true, false), + "NotReady → Ready must be a restart candidate" + ); + } + + /// Ready → NotReady (config became invalid, env changed) is admitted so the + /// agent restarts into setup-listener mode via the normal spawn path. + #[test] + fn ready_to_not_ready_env_changed_is_candidate() { + // old_ready=true (had key), new_ready=false (key removed), env_changed=true + assert!( + should_restart_on_config_change(true, false, true), + "Ready → NotReady with env change must be a restart candidate" + ); + } + + /// Both NotReady, env unchanged → not a candidate (nothing to restart). + #[test] + fn both_not_ready_unchanged_is_not_candidate() { + // old_ready=false, new_ready=false, env_changed=false + assert!( + !should_restart_on_config_change(false, false, false), + "both NotReady with no env change must NOT be a candidate" + ); + } + + /// NotReady + env changed but new still NotReady → not a candidate. + #[test] + fn not_ready_env_changed_still_not_ready_is_not_candidate() { + // Changed one unrelated env var but still missing the required key. + // old_ready=false, new_ready=false, env_changed=true + assert!( + !should_restart_on_config_change(false, false, true), + "NotReady→NotReady (env changed but still broken) must NOT be a candidate" + ); + } + + /// NotReady → Ready AND env also changed → still a restart candidate. + /// + /// Guards against a future `&& !env_changed` regression on the + /// NotReady→Ready branch: env_changed is irrelevant when readiness + /// unblocks — the agent must restart regardless of whether env also differed. + #[test] + fn not_ready_to_ready_with_env_change_is_candidate() { + // old_ready=false, new_ready=true, env_changed=true + assert!( + should_restart_on_config_change(false, true, true), + "NotReady → Ready (with env change) must be a restart candidate" + ); + } +} diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 2afa45c443..7a1892fba0 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -7,26 +7,45 @@ use tauri::State; use crate::{ app_state::AppState, models::IdentityInfo, + nostr_bind, relay::{self, relay_api_base_url_with_override, relay_ws_url_with_override}, }; +/// Encode `pubkey` as npub bech32 and truncate it for display: first 10 chars +/// + "…" + last 4 chars. Returns the full bech32 when it is 16 chars or fewer. +fn truncated_display_name(pubkey: &PublicKey) -> Result { + let bech32 = pubkey + .to_bech32() + .map_err(|error| format!("bech32 encode failed: {error}"))?; + Ok(if bech32.len() > 16 { + format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) + } else { + bech32 + }) +} + #[tauri::command] pub fn get_identity(state: State<'_, AppState>) -> Result { let keys = state.keys.lock().map_err(|error| error.to_string())?; let pubkey = keys.public_key(); let pubkey_hex = pubkey.to_hex(); - let bech32 = pubkey - .to_bech32() - .map_err(|error| format!("bech32 encode failed: {error}"))?; - let display_name = if bech32.len() > 16 { - format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) - } else { - bech32 - }; + let display_name = truncated_display_name(&pubkey)?; + let lost = state + .identity_lost + .load(std::sync::atomic::Ordering::Acquire); + let locked = state + .keyring_locked + .load(std::sync::atomic::Ordering::Acquire); + let reset_failed = state + .reset_failed + .load(std::sync::atomic::Ordering::Acquire); Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + lost, + locked, + reset_failed, }) } @@ -71,11 +90,7 @@ pub async fn sign_event( tags: Vec>, state: State<'_, AppState>, ) -> Result { - let keys = state - .keys - .lock() - .map_err(|error| error.to_string())? - .clone(); + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { let nostr_tags = tags @@ -103,13 +118,7 @@ pub fn decrypt_observer_event( event_json: String, state: State<'_, AppState>, ) -> Result { - let nsec = { - let keys = state.keys.lock().map_err(|error| error.to_string())?; - keys.secret_key() - .to_bech32() - .map_err(|error| format!("encode nsec: {error}"))? - }; - let keys = Keys::parse(&nsec).map_err(|error| format!("parse nsec: {error}"))?; + let keys = state.signing_keys()?; let event = Event::from_json(event_json).map_err(|error| format!("invalid event: {error}"))?; // Defense-in-depth: verify event ID and signature before decrypting. @@ -130,13 +139,7 @@ pub fn build_observer_control_event( payload: serde_json::Value, state: State<'_, AppState>, ) -> Result { - let nsec = { - let keys = state.keys.lock().map_err(|error| error.to_string())?; - keys.secret_key() - .to_bech32() - .map_err(|error| format!("encode nsec: {error}"))? - }; - let keys = Keys::parse(&nsec).map_err(|error| format!("parse nsec: {error}"))?; + let keys = state.signing_keys()?; let agent_pubkey = PublicKey::from_hex(agent_pubkey.trim()) .map_err(|error| format!("invalid agent pubkey: {error}"))?; let agent_pubkey_hex = agent_pubkey.to_hex(); @@ -158,50 +161,259 @@ pub fn build_observer_control_event( #[tauri::command] pub fn get_nsec(state: State<'_, AppState>) -> Result { - let keys = state.keys.lock().map_err(|error| error.to_string())?; + let keys = state.signing_keys()?; keys.secret_key() .to_bech32() .map_err(|error| format!("encode nsec: {error}")) } #[tauri::command] -pub fn import_identity( +pub async fn import_identity( nsec: String, app_handle: tauri::AppHandle, - state: State<'_, AppState>, ) -> Result { - let trimmed = nsec.trim(); - let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; + tokio::task::spawn_blocking(move || { + let trimmed = nsec.trim(); + let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; - // Persist to identity.key - let data_dir = app_handle + // Serialize against persist_current_identity: hold this guard for the + // full function body so a concurrent stale persist can't overwrite + // this import. + let state = app_handle.state::(); + let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?; + + let data_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("app data dir: {e}"))?; + std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; + let key_path = data_dir.join("identity.key"); + + // Persist into the OS keyring first (store → read-back verify → marker → + // delete file). Falls back to the 0o600 file when the keyring is + // unavailable; returns Err only when both backends fail. + let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; + + // Update in-memory keys BEFORE clearing recovery flags. The Release + // stores below pair with Acquire loads in get_identity: a reader + // observing false is guaranteed to see the updated keys. + let pubkey = keys.public_key(); + *state.keys.lock().map_err(|e| e.to_string())? = keys; + + // Clear both recovery flags — an import is valid in either lost or + // keyring-locked state and resolves both. In the locked case the + // keyring is unreachable, so persist_imported_identity already fell + // back to identity.key; on the next Unreachable boot the file is + // loaded directly and when the keyring returns the adoption path + // picks it up. + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + state + .keyring_locked + .store(false, std::sync::atomic::Ordering::Release); + + let pubkey_hex = pubkey.to_hex(); + let display_name = truncated_display_name(&pubkey)?; + + eprintln!("buzz-desktop: imported identity pubkey {}", pubkey_hex); + + Ok(IdentityInfo { + pubkey: pubkey_hex, + display_name, + lost: false, + locked: false, + reset_failed: false, + }) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Make the current ephemeral identity durable by persisting it to the OS +/// keyring (or falling back to identity.key). This is called when the user +/// chooses to start a new identity instead of re-importing their previous one +/// — it converts the transient lost-state key into a permanent identity. +/// +/// **LOST-ONLY**: returns `Err` when `identity_lost` is false, and deliberately +/// does NOT accept `keyring_locked`. In locked state the user's real identity +/// still exists in the unreachable keyring; persisting the ephemeral key to +/// `identity.key` would make it appear as a "different key" on next boot, +/// and the mismatched-file adoption path would then clobber the real keyring +/// key once the keyring becomes reachable again. The correct action in locked +/// state is to unlock the keyring and relaunch — not to adopt the ephemeral key. +#[tauri::command] +pub async fn persist_current_identity( + app_handle: tauri::AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app_handle.state::(); + + // Acquire mutation lock before reading identity_lost so that a + // concurrent import_identity cannot complete between our check and + // our persist, which would let the stale ephemeral key overwrite the + // imported one. + let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?; + + if !state + .identity_lost + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("identity is not in a lost state".to_string()); + } + + // Clone current keys without holding the mutex across keyring I/O. + let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + + let data_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("app data dir: {e}"))?; + std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; + let key_path = data_dir.join("identity.key"); + + let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; + + // Keys are already the live identity — only clear identity_lost. + // Release pairs with Acquire in get_identity so readers see + // consistent state. + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + + let pubkey = keys.public_key(); + let pubkey_hex = pubkey.to_hex(); + let display_name = truncated_display_name(&pubkey)?; + + Ok(IdentityInfo { + pubkey: pubkey_hex, + display_name, + lost: false, + locked: false, + reset_failed: false, + }) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Write a reset-intent sentinel and request a graceful restart into Phase 2 +/// (boot-time wipe). +/// +/// The actual data destruction is deferred to the next boot: `setup()` in +/// `lib.rs` checks for the sentinel and performs the wipe before any migration +/// or identity resolution. This two-phase design means a crash before the +/// restart is safe — the sentinel persists and the wipe completes on the next +/// open. +/// +/// Not available in shared-identity mode (`BUZZ_SHARE_IDENTITY=1`): the key +/// comes from an env var, not the keychain, so wiping would have no effect and +/// would be confusing. +#[tauri::command] +pub async fn sign_out(app: tauri::AppHandle) -> Result<(), String> { + if is_shared_identity() { + return Err( + "Sign out isn't available while BUZZ_SHARE_IDENTITY provides your identity. Unset BUZZ_SHARE_IDENTITY and BUZZ_PRIVATE_KEY, then relaunch to sign out." + .to_string(), + ); + } + + // Stop all managed agents before restart so they don't race the wipe. + if let Err(e) = crate::shutdown::shutdown_managed_agents(&app) { + eprintln!("buzz-desktop sign-out: agent shutdown: {e}"); + } + + // Write the reset sentinel — destruction happens on next boot. + let data_dir = app .path() .app_data_dir() .map_err(|e| format!("app data dir: {e}"))?; - std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; - let key_path = data_dir.join("identity.key"); - crate::app_state::save_key_file(&key_path, &keys)?; + crate::reset::write_sentinel(&data_dir)?; - // Update in-memory keys - let pubkey = keys.public_key(); - *state.keys.lock().map_err(|e| e.to_string())? = keys; + // Tauri restarts only after normal shutdown, avoiding a single-instance + // race. If restarting does not complete, the sentinel makes a manual open + // finish the reset. + app.request_restart(); + Ok(()) +} - let pubkey_hex = pubkey.to_hex(); - let bech32 = pubkey - .to_bech32() - .map_err(|error| format!("bech32 encode failed: {error}"))?; - let display_name = if bech32.len() > 16 { - format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) - } else { - bech32 - }; +fn nostr_bind_tag(name: &str, value: &str) -> Result { + Tag::parse(vec![name, value]).map_err(|error| format!("{name} tag failed: {error}")) +} - eprintln!("buzz-desktop: imported identity pubkey {}", pubkey_hex); +fn build_nostr_identity_binding_event( + keys: &Keys, + challenge_id: &str, + nonce: &str, + verification_code: &str, + origin: &str, + expires_at: &str, +) -> Result { + nostr_bind::validate_signing_request( + challenge_id, + nonce, + verification_code, + origin, + expires_at, + )?; - Ok(IdentityInfo { - pubkey: pubkey_hex, - display_name, + let tags = vec![ + nostr_bind_tag("challenge_id", challenge_id)?, + nostr_bind_tag("nonce", nonce)?, + nostr_bind_tag("verification_code", verification_code)?, + nostr_bind_tag("audience", nostr_bind::AUDIENCE)?, + nostr_bind_tag("action", nostr_bind::ACTION)?, + nostr_bind_tag("protocol", nostr_bind::PROTOCOL)?, + nostr_bind_tag("version", nostr_bind::VERSION)?, + nostr_bind_tag("origin", origin)?, + nostr_bind_tag("expires_at", expires_at)?, + ]; + + EventBuilder::new(Kind::Custom(nostr_bind::KIND), nostr_bind::CONTENT) + .tags(tags) + .sign_with_keys(keys) + .map_err(|error| format!("sign failed: {error}")) +} + +#[tauri::command] +pub async fn sign_nostr_identity_binding( + challenge_id: String, + nonce: String, + verification_code: String, + origin: String, + expires_at: String, + state: State<'_, AppState>, +) -> Result { + nostr_bind::validate_signing_request( + &challenge_id, + &nonce, + &verification_code, + &origin, + &expires_at, + )?; + + let keys = state + .keys + .lock() + .map_err(|error| error.to_string())? + .clone(); + + tauri::async_runtime::spawn_blocking(move || { + let event = build_nostr_identity_binding_event( + &keys, + &challenge_id, + &nonce, + &verification_code, + &origin, + &expires_at, + )?; + + Ok(event.as_json()) }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] @@ -210,11 +422,7 @@ pub async fn create_auth_event( relay_url: String, state: State<'_, AppState>, ) -> Result { - let keys = state - .keys - .lock() - .map_err(|error| error.to_string())? - .clone(); + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { let tags = vec![ @@ -240,7 +448,7 @@ pub async fn nip44_encrypt_to_self( plaintext: String, state: State<'_, AppState>, ) -> Result { - let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { nip44::encrypt( @@ -260,7 +468,7 @@ pub async fn nip44_decrypt_from_self( ciphertext: String, state: State<'_, AppState>, ) -> Result { - let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { nip44::decrypt(keys.secret_key(), &keys.public_key(), &ciphertext) @@ -269,3 +477,88 @@ pub async fn nip44_decrypt_from_self( .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } + +#[cfg(test)] +mod nostr_identity_binding_tests { + use super::build_nostr_identity_binding_event; + use crate::nostr_bind; + use nostr::{JsonUtil, Keys}; + + fn tag_values(event: &nostr::Event) -> Vec> { + event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect() + } + + #[test] + fn build_nostr_identity_binding_event_signs_exact_shape() { + let keys = Keys::generate(); + let event = build_nostr_identity_binding_event( + &keys, + "550e8400-e29b-41d4-a716-446655440000", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + "123456", + "https://example.com", + "2999-01-01T00:00:00Z", + ) + .unwrap(); + + assert_eq!(event.kind.as_u16(), nostr_bind::KIND); + assert_eq!(event.content, nostr_bind::CONTENT); + assert_eq!(event.pubkey, keys.public_key()); + assert!(event.verify_id()); + assert!(event.verify_signature()); + assert!(nostr::Event::from_json(event.as_json()).is_ok()); + + let tags = tag_values(&event); + assert!(tags.contains(&vec![ + "challenge_id".into(), + "550e8400-e29b-41d4-a716-446655440000".into(), + ])); + assert!(tags.contains(&vec![ + "nonce".into(), + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567".into(), + ])); + assert!(tags.contains(&vec!["verification_code".into(), "123456".into(),])); + assert!(tags.contains(&vec!["audience".into(), "buzz:nostr-identity".into()])); + assert!(tags.contains(&vec!["action".into(), "bind_nostr_identity".into(),])); + assert!(tags.contains(&vec!["protocol".into(), "buzz-nostr-identity".into(),])); + assert!(tags.contains(&vec!["version".into(), "1".into(),])); + assert!(tags.contains(&vec!["origin".into(), "https://example.com".into(),])); + assert!(tags.contains(&vec!["expires_at".into(), "2999-01-01T00:00:00Z".into(),])); + } + + #[test] + fn build_nostr_identity_binding_event_rejects_malformed_verification_code() { + let keys = Keys::generate(); + let error = build_nostr_identity_binding_event( + &keys, + "550e8400-e29b-41d4-a716-446655440000", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + "12345a", + "https://example.com", + "2999-01-01T00:00:00Z", + ) + .unwrap_err(); + + assert_eq!(error, "verification_code must be exactly 6 digits"); + } + + #[test] + fn build_nostr_identity_binding_event_rejects_expired_link() { + let keys = Keys::generate(); + let error = build_nostr_identity_binding_event( + &keys, + "550e8400-e29b-41d4-a716-446655440000", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + "123456", + "https://example.com", + "2000-01-01T00:00:00Z", + ) + .unwrap_err(); + + assert_eq!(error, "expires_at is expired"); + } +} diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index a40e14dedb..d15ee82abc 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -227,7 +227,7 @@ struct RelayInformationDocument { self_: Option, } -async fn fetch_relay_self(state: &AppState) -> Result, String> { +pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, String> { let relay_url = relay_ws_url_with_override(state); let http_url = relay_http_base_url(&relay_url); let response = state @@ -318,6 +318,18 @@ pub async fn list_archived_identities( }) } +/// Read the active relay's NIP-11 `self` pubkey (its own signing key, hex). +/// +/// A public, unauthenticated document read reused by the moderation UI to tell +/// whether a DM peer is the relay identity (a moderation DM). Fails open: an +/// unreachable relay, a document without `self`, or a malformed value all +/// return `None`, and callers must treat that as "not the relay" — the disable +/// is an affordance, not enforcement, so a false negative is the safe failure. +#[tauri::command] +pub async fn get_relay_self(state: State<'_, AppState>) -> Result, String> { + fetch_relay_self(&state).await +} + // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/legacy_storage.rs b/desktop/src-tauri/src/commands/legacy_storage.rs index eebd083dc5..fa7854d023 100644 --- a/desktop/src-tauri/src/commands/legacy_storage.rs +++ b/desktop/src-tauri/src/commands/legacy_storage.rs @@ -171,34 +171,39 @@ fn merge_legacy_workspace_storage( /// under `~/Library/WebKit//...` on macOS and is not included in the /// app data directory. #[tauri::command] -pub fn get_legacy_workspace_storage( +pub async fn get_legacy_workspace_storage( app: tauri::AppHandle, ) -> Result { - let Some(identifier) = legacy_identifier(&app.config().identifier) else { - return Ok(LegacyWorkspaceStorage::default()); - }; - let Some(root) = legacy_webkit_data_root(&identifier) else { - return Ok(LegacyWorkspaceStorage::default()); - }; - if !root.exists() { - return Ok(LegacyWorkspaceStorage::default()); - } - - let mut databases = Vec::new(); - collect_local_storage_databases(&root, &mut databases); - - let mut result = LegacyWorkspaceStorage::default(); - for database in databases { - match read_legacy_workspace_storage_db(&database) { - Ok(storage) => merge_legacy_workspace_storage(&mut result, storage), - Err(error) => eprintln!( - "buzz-desktop: legacy-local-storage-migration: {}: {error}", - database.display() - ), + let identifier = app.config().identifier.clone(); + tokio::task::spawn_blocking(move || { + let Some(identifier) = legacy_identifier(&identifier) else { + return Ok(LegacyWorkspaceStorage::default()); + }; + let Some(root) = legacy_webkit_data_root(&identifier) else { + return Ok(LegacyWorkspaceStorage::default()); + }; + if !root.exists() { + return Ok(LegacyWorkspaceStorage::default()); } - } - Ok(result) + let mut databases = Vec::new(); + collect_local_storage_databases(&root, &mut databases); + + let mut result = LegacyWorkspaceStorage::default(); + for database in databases { + match read_legacy_workspace_storage_db(&database) { + Ok(storage) => merge_legacy_workspace_storage(&mut result, storage), + Err(error) => eprintln!( + "buzz-desktop: legacy-local-storage-migration: {}: {error}", + database.display() + ), + } + } + + Ok(result) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 6c55613cb5..1950b2e29b 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -163,6 +163,73 @@ pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result { Ok(mime) } +/// Lifetime of a Blossom `t=get` read token. Ten minutes keeps a token alive +/// across a video's range-request stream while staying well inside the +/// server's `created_at` freshness window (3600s, matching upload). +pub(crate) const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; + +/// Sign a Blossom (BUD-01) `t=get` authorization event, server-scoped to the +/// relay's authority, and return the full `Authorization` header value. +/// +/// Server-scoped (a `server` tag, no `x` tag): one token authorizes reads of +/// any blob on that host for its lifetime, which keeps avatar-grid bursts and +/// video range requests cheap. This is deliberately broader than per-blob +/// scoping and is safe only because the relay still enforces NIP-43 +/// membership on the verified pubkey — and because callers only attach this +/// header to requests bound for the relay origin itself. +pub(crate) fn sign_blossom_get_auth_header( + keys: &Keys, + base_url: &str, + expiry_secs: u64, +) -> Result { + let server = extract_server_authority(base_url) + .ok_or_else(|| "cannot derive server authority from relay URL".to_string())?; + let now = Timestamp::now().as_secs(); + let tags = vec![ + Tag::parse(vec!["t", "get"]).map_err(|e| e.to_string())?, + Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()]) + .map_err(|e| e.to_string())?, + Tag::parse(vec!["server".to_string(), server]).map_err(|e| e.to_string())?, + ]; + let event = EventBuilder::new(Kind::from(24242), "Get buzz-media") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| e.to_string())?; + Ok(format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + )) +} + +/// Mint a `t=get` Authorization header value for a relay media fetch, or +/// `None` when signing is unavailable (identity in recovery mode). +/// +/// Fail-open by design: while the relay's `BUZZ_REQUIRE_MEDIA_GET_AUTH` flag +/// is off, an unauthenticated request still succeeds, so degrading to no +/// header (instead of erroring) keeps media rendering during key recovery. +/// Once the flag is on, these requests will 403 — the correct outcome for an +/// identity that can't prove membership. +/// +/// Safety contract: callers must only attach the returned header to URLs +/// constructed from (or validated against) the app's own relay base URL — +/// never to third-party origins, where the bearer token would leak. +pub(crate) fn mint_media_get_auth(state: &AppState, base_url: &str) -> Option { + let keys = match state.signing_keys() { + Ok(k) => k, + Err(e) => { + eprintln!("buzz-desktop: media get auth unavailable (unsigned request): {e}"); + return None; + } + }; + match sign_blossom_get_auth_header(&keys, base_url, MEDIA_GET_AUTH_EXPIRY_SECS) { + Ok(header) => Some(header), + Err(e) => { + eprintln!("buzz-desktop: media get auth signing failed (unsigned request): {e}"); + None + } + } +} + fn sign_blossom_upload_auth( keys: &Keys, sha256: &str, @@ -208,7 +275,7 @@ async fn do_upload( }; let base_url = relay_api_base_url_with_override(state); let auth_event = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)? }; @@ -300,9 +367,15 @@ pub async fn upload_media( /// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff → /// transcode-or-passthrough → MIME validation → upload). +/// +/// When `images_only` is set, the file is rejected **before upload** if it is +/// not an image (videos and non-image files error out; HEIC/HEIF still +/// transcode to JPEG, which is an image). This keeps discarded/non-image +/// files from ever leaving the client on image-only surfaces. async fn process_picked_path( path: std::path::PathBuf, state: &State<'_, AppState>, + images_only: bool, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a // local attacker from swapping the file between dialog return and read. @@ -325,6 +398,9 @@ async fn process_picked_path( let n = file.read(&mut header).map_err(|e| e.to_string())?; if is_video_file(&header[..n]) { + if images_only { + return Err("Please choose an image file.".to_string()); + } // ffmpeg needs a path, not an fd. Resolve the fd's real path // so we pass the actual inode's location, not the original // (potentially swapped) pathname. Same pattern as upload_media. @@ -357,6 +433,12 @@ async fn process_picked_path( let mime = detect_and_validate_mime(&body)?; + // Image-only surfaces (e.g. "Send feedback"): reject anything that didn't + // sniff as an image, BEFORE the upload leaves the client. + if images_only && !mime.starts_with("image/") { + return Err("Please choose an image file.".to_string()); + } + // Upload video first, then poster (best-effort). If poster upload fails, // the video descriptor is returned without an image field. let mut descriptor = do_upload(body, &mime, state, None).await?; @@ -414,13 +496,51 @@ pub async fn pick_and_upload_media( let mut descriptors = Vec::with_capacity(file_paths.len()); for file_path in file_paths { let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state).await?; + let descriptor = process_picked_path(path, &state, false).await?; descriptors.push(descriptor); } Ok(descriptors) } +/// Open a native single-file dialog constrained to images, read the picked +/// file, and upload it — rejecting anything that doesn't sniff as an image +/// **before** the bytes leave the client. +/// +/// This is the secure path for image-only surfaces (e.g. the "Send feedback" +/// attachment). Unlike [`pick_and_upload_media`], the dialog is filtered to +/// common image extensions and `process_picked_path` runs with +/// `images_only = true`, so a user who bypasses the extension filter still +/// can't upload a non-image (videos and other files error out during MIME +/// validation, before `do_upload`). Returns `None` when the user cancels. +#[tauri::command] +pub async fn pick_and_upload_image( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter( + "Images", + &["png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "bmp"], + ) + .pick_file(move |path| { + let _ = tx.send(path); + }); + + let file_path = match rx.await.map_err(|_| "dialog cancelled".to_string())? { + Some(path) => path, + None => return Ok(None), + }; + + let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); + let descriptor = process_picked_path(path, &state, true).await?; + Ok(Some(descriptor)) +} + /// Upload raw bytes directly (for paste and drag-drop). /// /// The renderer already has the bytes in memory from the clipboard/drag event. @@ -543,6 +663,38 @@ mod tests { assert_eq!(extract_server_authority(""), None); } + #[test] + fn test_sign_blossom_get_auth_header_shape() { + let keys = Keys::generate(); + let header = sign_blossom_get_auth_header(&keys, "http://localhost:3000", 600).unwrap(); + let b64 = header.strip_prefix("Nostr ").expect("Nostr scheme prefix"); + let json = URL_SAFE_NO_PAD.decode(b64).unwrap(); + let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); + + assert_eq!(event.kind, Kind::from(24242)); + event.verify().expect("valid signature"); + + let tag = |name: &str| -> Option { + event.tags.iter().find_map(|t| { + let v = t.as_slice(); + (v.first().map(String::as_str) == Some(name)).then(|| v[1].clone()) + }) + }; + assert_eq!(tag("t").as_deref(), Some("get")); + assert_eq!(tag("server").as_deref(), Some("localhost:3000")); + // Server-scoped token: no x tag (BUD-01 allows x OR server). + assert!(tag("x").is_none()); + let expiration: u64 = tag("expiration").unwrap().parse().unwrap(); + let now = Timestamp::now().as_secs(); + assert!(expiration > now && expiration <= now + 600); + } + + #[test] + fn test_sign_blossom_get_auth_header_invalid_base_url() { + let keys = Keys::generate(); + assert!(sign_blossom_get_auth_header(&keys, "not-a-url", 600).is_err()); + } + #[test] fn test_detect_and_validate_mime_jpeg() { // Minimal JPEG: SOI + EOI diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index c9ce4494ea..dcc57819b8 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -1,12 +1,20 @@ use futures_util::StreamExt; +use sha2::{Digest, Sha256}; use tauri::State; use crate::app_state::AppState; use crate::commands::export_util::save_bytes_with_dialog; +use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename}; +use crate::commands::{ + personas::{ + decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, PNG_MAGIC, + }, + team_snapshot::{ + decode_team_snapshot_from_bytes, MAX_TEAM_SNAPSHOT_JSON_BYTES, MAX_TEAM_SNAPSHOT_PNG_BYTES, + }, +}; use crate::relay::{classify_request_error, relay_api_base_url_with_override, relay_error_message}; -use super::media::{detect_and_validate_mime, sanitize_filename}; - /// Maximum download size: 50 MiB. Prevents OOM from oversized responses. const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; @@ -129,18 +137,151 @@ pub async fn download_file( save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await } +/// Fetch relay media bytes for the composer image editor. +/// +/// The editor composites the image onto a canvas and needs pixel access. +/// Handing the webview raw bytes over IPC (which it wraps in a same-origin +/// `blob:` URL) keeps the canvas un-tainted without involving CORS — and +/// therefore without any media-proxy header or origin-gate changes. +/// +/// Same SSRF validation, size cap, and content policy as the download +/// commands above. +/// +/// Returns `tauri::ipc::Response` so the bytes cross IPC as a raw buffer +/// instead of a JSON number array (which would be ~3x the size to +/// serialize and deserialize at the 50 MiB cap). +#[tauri::command] +pub async fn fetch_media_bytes( + url: String, + state: State<'_, AppState>, +) -> Result { + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + + let bytes = fetch_blob_bytes(&url, &state).await?; + detect_and_validate_mime(&bytes)?; + Ok(tauri::ipc::Response::new(bytes)) +} + +/// Copy an image from a relay media URL directly to the system clipboard. +/// +/// Fetches the image, decodes it to RGBA8, and writes it to the clipboard via +/// `arboard`. Same SSRF validation, size cap, and content policy as the download +/// commands above. +/// +/// `arboard` requires clipboard access on the main thread on macOS, so the +/// write is dispatched via `run_on_main_thread` and the result is relayed back +/// through a one-shot channel. +#[tauri::command] +pub async fn copy_image_to_clipboard( + url: String, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + + let bytes = fetch_blob_bytes(&url, &state).await?; + detect_and_validate_mime(&bytes)?; + + let img = + image::load_from_memory(&bytes).map_err(|e| format!("failed to decode image: {e}"))?; + + // Guard against decompression bombs: a small compressed file can decode to + // a huge RGBA buffer. Cap at 50 MiB (matching the download size cap). + let pixels = img.width() as u64 * img.height() as u64; + if pixels * 4 > MAX_DOWNLOAD_BYTES { + return Err("image too large to copy to clipboard".to_string()); + } + + let rgba = img.to_rgba8(); + let (width, height) = (rgba.width() as usize, rgba.height() as usize); + let raw = rgba.into_raw(); + + // arboard requires main-thread access on macOS. Use a sync channel so the + // async command can await the result. + let (tx, rx) = std::sync::mpsc::sync_channel::>(1); + app.run_on_main_thread(move || { + let result = arboard::Clipboard::new() + .map_err(|e| format!("clipboard error: {e}")) + .and_then(|mut clipboard| { + clipboard + .set_image(arboard::ImageData { + width, + height, + bytes: std::borrow::Cow::Owned(raw), + }) + .map_err(|e| format!("clipboard error: {e}")) + }); + // Ignore send errors — the receiver dropped only if the command was + // cancelled, in which case nobody is waiting for the result. + let _ = tx.send(result); + }) + .map_err(|e| format!("main thread dispatch failed: {e}"))?; + + rx.recv() + .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? +} + +/// Write text to the system clipboard through the native shell. +/// +/// WebKit can revoke browser clipboard permission after a user action awaits a +/// long-running operation such as snapshot encoding and upload. Keeping the +/// delayed write in the native layer makes that flow reliable on macOS. +#[tauri::command] +pub async fn copy_text_to_clipboard( + text: String, + html: Option, + app: tauri::AppHandle, +) -> Result<(), String> { + let (tx, rx) = std::sync::mpsc::sync_channel::>(1); + app.run_on_main_thread(move || { + let result = arboard::Clipboard::new() + .map_err(|e| format!("clipboard error: {e}")) + .and_then(|mut clipboard| { + if let Some(html) = html { + clipboard + .set_html(html, Some(text)) + .map_err(|e| format!("clipboard error: {e}")) + } else { + clipboard + .set_text(text) + .map_err(|e| format!("clipboard error: {e}")) + } + }); + let _ = tx.send(result); + }) + .map_err(|e| format!("main thread dispatch failed: {e}"))?; + + rx.recv() + .map_err(|_| "clipboard result channel closed unexpectedly".to_string())? +} + /// Fetch blob bytes from a (pre-validated) relay media URL through the app's /// HTTP client, enforcing the download size cap. The caller is responsible for /// validating the URL origin and for any content-type checks on the result. async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result, String> { + fetch_blob_bytes_with_cap(url, state, MAX_DOWNLOAD_BYTES).await +} + +/// Core streaming fetcher with a caller-supplied byte cap. +async fn fetch_blob_bytes_with_cap( + url: &str, + state: &State<'_, AppState>, + cap: u64, +) -> Result, String> { // Fetch bytes via the app's HTTP client (goes through WARP tunnel). - let resp = state - .http_client - .get(url) - .timeout(DOWNLOAD_TIMEOUT) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + let mut req = state.http_client.get(url).timeout(DOWNLOAD_TIMEOUT); + + // Every caller pre-validates `url` against the relay origin via + // `validate_download_url`, satisfying the mint_media_get_auth safety + // contract (the token never leaves the relay origin). + let relay_base = relay_api_base_url_with_override(state); + if let Some(auth) = mint_media_get_auth(state, &relay_base) { + req = req.header("authorization", auth); + } + + let resp = req.send().await.map_err(|e| classify_request_error(&e))?; if !resp.status().is_success() { return Err(relay_error_message(resp).await); @@ -148,11 +289,11 @@ async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result MAX_DOWNLOAD_BYTES { + if content_length > cap { return Err(format!( "file too large ({} MiB, max {} MiB)", content_length / (1024 * 1024), - MAX_DOWNLOAD_BYTES / (1024 * 1024) + cap / (1024 * 1024) )); } } @@ -163,11 +304,8 @@ async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result MAX_DOWNLOAD_BYTES { - return Err(format!( - "file too large (max {} MiB)", - MAX_DOWNLOAD_BYTES / (1024 * 1024) - )); + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024))); } bytes.extend_from_slice(&chunk); } @@ -175,10 +313,444 @@ async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result u64 { + match self { + SnapshotFileKind::AgentJson => MAX_SNAPSHOT_JSON_BYTES as u64, + SnapshotFileKind::AgentPng => MAX_SNAPSHOT_PNG_BYTES as u64, + SnapshotFileKind::TeamJson => MAX_TEAM_SNAPSHOT_JSON_BYTES as u64, + SnapshotFileKind::TeamPng => MAX_TEAM_SNAPSHOT_PNG_BYTES as u64, + } + } + + fn is_png(self) -> bool { + matches!(self, SnapshotFileKind::AgentPng | SnapshotFileKind::TeamPng) + } + + fn label(self) -> &'static str { + match self { + SnapshotFileKind::AgentJson => ".agent.json", + SnapshotFileKind::AgentPng => ".agent.png", + SnapshotFileKind::TeamJson => ".team.json", + SnapshotFileKind::TeamPng => ".team.png", + } + } +} + +/// Verify that the leading bytes of `bytes` are consistent with `kind`. +/// +/// PNG magic (`\x89PNG`) is required for `Png` and must be absent for `Json`. +/// Returns an error with a clear message on mismatch so that `fetch_snapshot_bytes` +/// fails closed before any bytes reach the frontend. +fn ensure_bytes_match_kind(bytes: &[u8], kind: SnapshotFileKind) -> Result<(), String> { + let has_png_magic = bytes.len() >= 4 && bytes[..4] == PNG_MAGIC; + if kind.is_png() && !has_png_magic { + Err(format!( + "format mismatch: filename is {} but bytes are not a PNG", + kind.label() + )) + } else if !kind.is_png() && has_png_magic { + Err(format!( + "format mismatch: filename is {} but bytes are a PNG", + kind.label() + )) + } else { + Ok(()) + } +} + +/// Determine whether a sanitized filename is a valid agent snapshot candidate. +/// Returns the `SnapshotFileKind` for the extension, or an error. +fn snapshot_kind_for_filename(filename: &str) -> Result { + let lower = filename.to_ascii_lowercase(); + if lower.ends_with(".agent.json") { + Ok(SnapshotFileKind::AgentJson) + } else if lower.ends_with(".agent.png") { + Ok(SnapshotFileKind::AgentPng) + } else if lower.ends_with(".team.json") { + Ok(SnapshotFileKind::TeamJson) + } else if lower.ends_with(".team.png") { + Ok(SnapshotFileKind::TeamPng) + } else { + Err(format!( + "\"{}\" is not a snapshot filename — expected .agent.json, .agent.png, .team.json, or .team.png", + filename + )) + } +} + +/// Reject a metadata-declared size before opening the media stream. +/// +/// Keeping this as a pure helper makes the per-kind cap a testable production +/// boundary, rather than relying on a duplicated test-side comparison. +fn ensure_declared_size_within_cap( + expected_size: usize, + kind: SnapshotFileKind, +) -> Result<(), String> { + if expected_size as u64 > kind.cap() { + return Err(format!( + "declared size {} exceeds the {} MiB cap for this format", + expected_size, + kind.cap() / (1024 * 1024) + )); + } + Ok(()) +} + +/// Fetch and validate an agent or team snapshot attachment in memory. +/// +/// Input validation (before HTTP): +/// - URL must be a valid same-relay `/media/` URL. +/// - Filename must end case-insensitively with `.agent.json`, `.agent.png`, +/// `.team.json`, or `.team.png`. +/// - `expected_sha256` and `expected_size` must be non-empty strings. +/// +/// During fetch, `SnapshotFileKind::cap()` enforces the kind-specific cap via +/// Content-Length and streamed byte count: 5 MiB JSON / 10 MiB PNG for agents, +/// or 25 MiB JSON / 50 MiB PNG for teams. +/// +/// Post-fetch validation (all must pass; returns an error on first failure): +/// 1. Byte length equals `expected_size`. +/// 2. SHA-256 hex of bytes equals `expected_sha256` (lowercase). +/// 3. The byte magic matches the filename-selected kind. +/// 4. Agent kinds pass `decode_snapshot_from_bytes`; team kinds pass +/// `decode_team_snapshot_from_bytes`. +/// +/// Returns `tauri::ipc::Response` so bytes cross IPC as a raw buffer rather +/// than a JSON number array (which would be ~3× the size at the applicable cap). +#[tauri::command] +pub async fn fetch_snapshot_bytes( + url: String, + filename: String, + expected_sha256: String, + expected_size: usize, + state: State<'_, AppState>, +) -> Result { + // ── Pre-fetch validation ────────────────────────────────────────────── + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + + // Sanitize the filename and verify it is a recognised snapshot extension. + let filename = sanitize_filename(&filename); + let kind = snapshot_kind_for_filename(&filename)?; + let cap = kind.cap(); + + if expected_sha256.is_empty() { + return Err("missing expected sha256 (imeta x field)".to_string()); + } + if expected_sha256.len() != 64 || !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err( + "invalid expected sha256 — must be a 64-hex-digit lowercase string".to_string(), + ); + } + if expected_size == 0 { + return Err("missing or zero expected size (imeta size field)".to_string()); + } + ensure_declared_size_within_cap(expected_size, kind)?; + + // ── Bounded fetch ───────────────────────────────────────────────────── + let bytes = fetch_blob_bytes_with_cap(&url, &state, cap).await?; + + // ── Post-fetch validation ───────────────────────────────────────────── + // 1. Byte length must equal the declared imeta size. + if bytes.len() != expected_size { + return Err(format!( + "size mismatch: fetched {} bytes but imeta declared {}", + bytes.len(), + expected_size + )); + } + + // 2. SHA-256 must match the declared imeta x value. + let actual_sha256 = hex::encode(Sha256::digest(&bytes)); + if actual_sha256 != expected_sha256.to_ascii_lowercase() { + return Err("hash mismatch: fetched bytes do not match the declared SHA-256".to_string()); + } + + // 3. Byte magic must match the expected kind (filename → format), so a + // filename cannot select a JSON or PNG parser for the other format. + ensure_bytes_match_kind(&bytes, kind)?; + + // 4. Bytes must parse as the snapshot type selected by the filename. + // Team parsing rejects retired flat JSON and persona-pack ZIP inputs + // before anything reaches the frontend. + match kind { + SnapshotFileKind::AgentJson | SnapshotFileKind::AgentPng => { + decode_snapshot_from_bytes(&bytes) + .map_err(|e| format!("invalid agent snapshot: {e}"))?; + } + SnapshotFileKind::TeamJson | SnapshotFileKind::TeamPng => { + decode_team_snapshot_from_bytes(&bytes) + .map_err(|e| format!("invalid team snapshot: {e}"))?; + } + } + + Ok(tauri::ipc::Response::new(bytes)) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn snapshot_kind_json_returns_json_kind_and_correct_cap() { + let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); + assert_eq!(kind, SnapshotFileKind::AgentJson); + assert_eq!(kind.cap(), MAX_SNAPSHOT_JSON_BYTES as u64); + } + + #[test] + fn snapshot_kind_png_returns_png_kind_and_correct_cap() { + let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); + assert_eq!(kind, SnapshotFileKind::AgentPng); + assert_eq!(kind.cap(), MAX_SNAPSHOT_PNG_BYTES as u64); + } + + #[test] + fn snapshot_kind_plain_json_rejected() { + assert!(snapshot_kind_for_filename("data.json").is_err()); + } + + #[test] + fn snapshot_kind_deceptive_name_rejected() { + // foo.agent.json.exe must not match .agent.json + assert!(snapshot_kind_for_filename("foo.agent.json.exe").is_err()); + } + + #[test] + fn snapshot_kind_plain_png_rejected() { + assert!(snapshot_kind_for_filename("photo.png").is_err()); + } + + #[test] + fn snapshot_kind_agent_json_only_rejected() { + // "agent.json" without the leading dot — plain filename, not the suffix + assert!(snapshot_kind_for_filename("agentjson").is_err()); + } + + #[test] + fn snapshot_kind_team_extensions_are_case_insensitive_and_scale_caps() { + let json = snapshot_kind_for_filename("review.TEAM.JSON").unwrap(); + let png = snapshot_kind_for_filename("review.TEAM.PNG").unwrap(); + assert_eq!(json, SnapshotFileKind::TeamJson); + assert_eq!(png, SnapshotFileKind::TeamPng); + assert_eq!(json.cap(), 25 * 1024 * 1024); + assert_eq!(png.cap(), 50 * 1024 * 1024); + } + + #[test] + fn fetch_boundary_team_png_filename_with_json_bytes_rejected() { + let bytes = br#"{"format":"buzz-team-snapshot","version":1}"#; + let kind = snapshot_kind_for_filename("review.team.png").unwrap(); + let error = ensure_bytes_match_kind(bytes, kind).unwrap_err(); + assert!(error.contains(".team.png") && error.contains("not a PNG")); + } + + #[test] + fn fetch_boundary_team_declared_size_over_cap_rejected() { + let kind = snapshot_kind_for_filename("review.team.json").unwrap(); + assert!(ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES, kind).is_ok()); + let error = + ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES + 1, kind).unwrap_err(); + assert!(error.contains("25 MiB")); + } + + // ── Focused boundary tests: format mismatch and consistency ────────────── + // + // These tests exercise the guard logic that fetch_snapshot_bytes applies + // after the bounded fetch + hash check. The validation has two layers: + // + // 1. Magic-byte kind check: filename kind (from snapshot_kind_for_filename) + // must match the actual byte format (PNG magic or absence of it). + // 2. decode_snapshot_from_bytes: rejects malformed manifests including + // JSON with level:none + non-empty entries. + // + // We verify each rejection path directly — no live HTTP required. + + #[test] + fn fetch_boundary_png_filename_with_json_bytes_rejected() { + use crate::managed_agents::agent_snapshot::{ + encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }; + let snapshot = AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "test".to_string(), + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::None, + entries: vec![], + }, + }; + let json_bytes = encode_snapshot_json(&snapshot).unwrap(); + // .agent.png filename → Png kind; JSON bytes must be rejected. + let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); + let result = ensure_bytes_match_kind(&json_bytes, kind); + assert!( + result.is_err(), + ".agent.png filename with JSON bytes must be rejected by the magic-byte guard" + ); + assert!( + result.unwrap_err().contains("not a PNG"), + "error must describe the mismatch" + ); + } + + #[test] + fn fetch_boundary_png_filename_with_memory_bearing_json_bytes_rejected() { + use crate::managed_agents::agent_snapshot::{ + encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotMemoryEntry, AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }; + // This is the trust-hole case: memory-bearing JSON delivered under a + // .agent.png label to bypass the PNG no-memory policy. + let snapshot = AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "test".to_string(), + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::Everything, + entries: vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "Secret memory.".to_string(), + }], + }, + }; + let json_bytes = encode_snapshot_json(&snapshot).unwrap(); + let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); + let result = ensure_bytes_match_kind(&json_bytes, kind); + assert!( + result.is_err(), + ".agent.png filename with memory-bearing JSON bytes must be rejected" + ); + } + + #[test] + fn fetch_boundary_json_filename_with_png_bytes_rejected() { + use crate::managed_agents::agent_snapshot::{ + encode_snapshot_png, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }; + let snapshot = AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "test".to_string(), + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::None, + entries: vec![], + }, + }; + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + // .agent.json filename → Json kind; PNG bytes must be rejected. + let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); + let result = ensure_bytes_match_kind(&png_bytes, kind); + assert!( + result.is_err(), + ".agent.json filename with PNG bytes must be rejected by the magic-byte guard" + ); + assert!( + result.unwrap_err().contains("bytes are a PNG"), + "error must describe the mismatch" + ); + } + + #[test] + fn decode_boundary_json_none_level_with_entries_rejected() { + use crate::commands::personas::decode_snapshot_from_bytes; + // Construct JSON bytes directly: level=none but entries non-empty. + // encode_snapshot_json does not guard against this, so we can produce it. + let raw = serde_json::json!({ + "format": "buzz-agent-snapshot", + "version": 1, + "definition": { "name": "test" }, + "profile": { "displayName": "Test" }, + "memory": { + "level": "none", + "entries": [{"slug": "core", "body": "leaked"}] + } + }); + let bytes = serde_json::to_vec(&raw).unwrap(); + let result = decode_snapshot_from_bytes(&bytes); + assert!( + result.is_err(), + "JSON with level:none + non-empty entries must be rejected by decode_snapshot_from_bytes" + ); + assert!( + result + .unwrap_err() + .contains("'none' but entries are present"), + "error must describe the consistency violation" + ); + } + const RELAY_BASE: &str = "https://relay.example.com"; #[test] diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index ecac205884..36cb458db1 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -1,33 +1,114 @@ +use std::path::PathBuf; + use tauri::{AppHandle, Manager, State}; -use crate::{app_state::AppState, managed_agents::RELAY_MESH_API_BASE_URL, mesh_llm, relay}; +use crate::{app_state::AppState, mesh_llm, relay}; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct MeshSharingConfig { + enabled: bool, + model_id: String, + max_vram_gb: Option, +} + +fn mesh_sharing_config_path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| format!("failed to resolve app data dir: {error}"))? + .join("mesh-sharing.json")) +} + +fn save_mesh_sharing_config(app: &AppHandle, config: &MeshSharingConfig) -> Result<(), String> { + let path = mesh_sharing_config_path(app)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create mesh config directory: {error}"))?; + } + let payload = serde_json::to_vec_pretty(config) + .map_err(|error| format!("failed to encode mesh sharing config: {error}"))?; + crate::managed_agents::atomic_write_json(&path, &payload) +} + +fn load_mesh_sharing_config(app: &AppHandle) -> Result, String> { + let path = mesh_sharing_config_path(app)?; + match std::fs::read(&path) { + Ok(payload) => serde_json::from_slice(&payload) + .map(Some) + .map_err(|error| format!("failed to parse {}: {error}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("failed to read {}: {error}", path.display())), + } +} const RELAY_MESH_RUNTIME_NO_TARGET: &str = - "relay mesh client start requires a concrete serve target; reopen the agent with Run on relay mesh selected to refresh its target"; + "Buzz shared compute requires a live serving member; start serving the selected model on a member, then try again"; pub type CmdResult = Result; -#[tauri::command] -pub async fn mesh_availability( - state: State<'_, AppState>, -) -> CmdResult { - match relay::query_relay(&state, &[mesh_llm::mesh_status_filter()]).await { - Ok(events) => Ok(mesh_llm::availability_from_events(events)), - Err(error) => Ok(mesh_llm::MeshAvailability::unavailable(error)), +/// Resolve the admission roster by intersecting member-signed mesh status +/// reporters with the current NIP-43 direct-member list. Missing membership or +/// a failed query returns an empty roster, which the runtime normalizes to +/// self-only admission. +pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Vec { + let filters = [ + mesh_llm::mesh_status_filter(), + mesh_llm::relay_membership_filter(), + ]; + match relay::query_relay(state, &filters).await { + Ok(events) => mesh_llm::owner_ids_from_events(&events), + Err(error) => { + eprintln!("buzz-mesh: roster query failed; allowing only this node: {error}"); + Vec::new() + } } } +pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> CmdResult<()> { + let Some(config) = load_mesh_sharing_config(app)? else { + return Ok(()); + }; + if !config.enabled || config.model_id.trim().is_empty() { + return Ok(()); + } + let mut runtime = state.mesh_llm_runtime.lock().await; + if runtime.is_some() { + return Ok(()); + } + let request = mesh_llm::StartMeshNodeRequest { + mode: mesh_llm::MeshNodeMode::Serve, + model_id: Some(config.model_id), + max_vram_gb: config.max_vram_gb, + join_token: None, + trusted_owner_ids: Some(resolve_trusted_owner_ids(state).await), + }; + let started = mesh_llm::DesktopMeshRuntime::start(request) + .await + .map_err(|error| format!("failed to restore Share Compute: {error}"))?; + *runtime = Some(started); + drop(runtime); + mesh_llm::publish_current_status_once(app, "restore").await; + Ok(()) +} + #[tauri::command] pub async fn mesh_start_node( app: AppHandle, state: State<'_, AppState>, - request: mesh_llm::StartMeshNodeRequest, + mut request: mesh_llm::StartMeshNodeRequest, ) -> CmdResult { + // Frontend requests never carry a roster; resolve it here so every + // UI-started node enforces the member allowlist. + if request.trusted_owner_ids.is_none() { + request.trusted_owner_ids = Some(resolve_trusted_owner_ids(&state).await); + } let mut runtime = state.mesh_llm_runtime.lock().await; if runtime.is_some() { return Err("mesh node is already running".to_string()); } + let saved_request = request.clone(); let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| error.to_string())?; @@ -37,128 +118,61 @@ pub async fn mesh_start_node( .map_err(|error| format!("mesh node started but status probe failed: {error}"))?; *runtime = Some(started); drop(runtime); + if saved_request.mode == mesh_llm::MeshNodeMode::Serve { + if let Some(model_id) = saved_request.model_id.as_deref() { + save_mesh_sharing_config( + &app, + &MeshSharingConfig { + enabled: true, + model_id: model_id.to_string(), + max_vram_gb: saved_request.max_vram_gb, + }, + )?; + } + } mesh_llm::publish_current_status_once(&app, "start").await; Ok(status) } -#[tauri::command] -pub async fn mesh_ensure_client_node( - state: State<'_, AppState>, - request: mesh_llm::EnsureMeshClientRequest, -) -> CmdResult { - ensure_client_node_for_model(&state, request.model_id, request.endpoint_addr).await -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PrepareRelayMeshClientRequest { - pub model_id: String, - pub target: mesh_llm::MeshServeTarget, -} - -/// Fresh-create preflight for relay-mesh agents. Starts/dials the local mesh -/// client and sends the paired connect-request through the Rust coordinator so -/// fresh-created and saved relay-mesh agents use the same signaling path. -#[tauri::command] -pub async fn mesh_prepare_relay_mesh_client( - app: AppHandle, - state: State<'_, AppState>, - request: PrepareRelayMeshClientRequest, -) -> CmdResult { - prepare_relay_mesh_client(&app, &state, &request.model_id, request.target).await -} - -pub(crate) async fn prepare_relay_mesh_client( - app: &AppHandle, - state: &AppState, - model_id: &str, - target: mesh_llm::MeshServeTarget, -) -> CmdResult { - let target_pubkey = normalize_pubkey(target.reporter_pubkey.as_deref()) - .ok_or_else(|| "Selected relay mesh target is missing its reporter pubkey.".to_string())?; - let status = - ensure_client_node_for_model(state, model_id, Some(target.endpoint_addr.clone())).await?; - let self_pubkey = workspace_pubkey(state)?; - if self_pubkey == target_pubkey { - return Ok(status); - } - let self_addr = status - .invite_token - .as_deref() - .ok_or_else(|| "Local mesh client did not publish an endpoint address.".to_string())?; - crate::mesh_llm::start_client( - app, - crate::mesh_llm::RelayMeshConnectRequest { - target_pubkey: &target_pubkey, - peer_endpoint_addr: &target.endpoint_addr, - self_endpoint_addr: self_addr, - peer_endpoint_id: target.endpoint_id.as_deref(), - self_endpoint_id: status.endpoint_id.as_deref(), - }, - ) - .await?; - Ok(status) -} - -fn normalize_pubkey(value: Option<&str>) -> Option { - let normalized = value?.trim().to_ascii_lowercase(); - if normalized.len() == 64 && normalized.chars().all(|c| c.is_ascii_hexdigit()) { - Some(normalized) - } else { - None - } -} - -fn workspace_pubkey(state: &AppState) -> Result { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - Ok(keys.public_key().to_hex()) -} - -/// Join a peer by endpoint addr without naming a model. Used by the runtime -/// coordinator's call-me-now responder and the initiator's same-attempt dial: -/// the responder side of a hole-punch just needs both ends dialing, and the -/// mesh-llm router resolves per-model routability per request afterward. -/// -/// Dials into the running runtime if one exists; otherwise starts a client -/// node with the addr as its join token. Model-agnostic on purpose. -pub(crate) async fn ensure_client_node_for_model_dial_only( - state: &AppState, - endpoint_addr: &str, -) -> CmdResult<()> { - let addr = endpoint_addr.trim(); - if addr.is_empty() { - return Err("endpoint_addr is required to dial".to_string()); - } - { - let runtime = state.mesh_llm_runtime.lock().await; - if let Some(runtime) = runtime.as_ref() { - return runtime - .dial_endpoint_addr(addr) - .await - .map_err(|error| format!("mesh dial failed: {error}")); +/// Mesh can bind its HTTP ingress and advertise a model shortly before the +/// router has installed a usable target. Probe the exact chat path agents use +/// so startup cannot race that gap (`single target None unavailable`). +async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|error| format!("failed to build mesh readiness client: {error}"))?; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120); + let mut last_error = "mesh inference is not ready".to_string(); + while tokio::time::Instant::now() < deadline { + match client + .post(format!( + "{}/chat/completions", + crate::managed_agents::RELAY_MESH_API_BASE_URL + )) + .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) + .json(&serde_json::json!({ + "model": model_id, + "messages": [{"role": "user", "content": "Reply OK"}], + "max_tokens": 1, + "stream": false + })) + .send() + .await + { + Ok(response) if response.status().is_success() => return Ok(()), + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + last_error = format!("HTTP {status}: {body}"); + } + Err(error) => last_error = error.to_string(), } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; } - let start = mesh_llm::StartMeshNodeRequest { - mode: mesh_llm::MeshNodeMode::Client, - model_id: None, - max_vram_gb: None, - join_token: Some(addr.to_string()), - }; - let mut runtime = state.mesh_llm_runtime.lock().await; - if runtime.is_some() { - // Lost a race; dial into the now-present runtime instead. - if let Some(runtime) = runtime.as_ref() { - return runtime - .dial_endpoint_addr(addr) - .await - .map_err(|error| format!("mesh dial failed: {error}")); - } - } - let started = mesh_llm::DesktopMeshRuntime::start(start) - .await - .map_err(|error| format!("mesh client failed to start: {error}"))?; - *runtime = Some(started); - Ok(()) + Err(format!( + "Buzz shared compute did not become inference-ready for {model_id}: {last_error}" + )) } pub(crate) async fn ensure_client_node_for_model( @@ -218,10 +232,11 @@ pub(crate) async fn ensure_client_node_for_model( model_id: None, max_vram_gb: None, join_token: Some(join_token), + trusted_owner_ids: Some(resolve_trusted_owner_ids(state).await), }; let mut runtime = state.mesh_llm_runtime.lock().await; if runtime.is_some() { - return Err("mesh node changed while starting relay mesh client".to_string()); + return Err("mesh node changed while starting Buzz shared compute client".to_string()); } let started = mesh_llm::DesktopMeshRuntime::start(start) .await @@ -237,7 +252,7 @@ pub(crate) async fn ensure_client_node_for_model( /// Re-resolve a live serve target's dial pointer for a saved relay-mesh agent. /// /// The serve target's `endpoint_addr` is live discovery state — it comes from -/// the peer's replaceable kind:30621 status event and rotates when the peer's +/// the peer's client-signed mesh status event and rotates when the peer's /// iroh endpoint changes — so it is never persisted onto the agent record. /// Instead, a saved agent re-resolves a current bootstrap target at start time /// by matching its configured model against the targets the relay is gossiping @@ -257,7 +272,14 @@ pub(crate) async fn resolve_mesh_bootstrap_target( if model_id.is_empty() { return Ok(None); } - let events = relay::query_relay(state, &[mesh_llm::mesh_status_filter()]).await?; + let events = relay::query_relay( + state, + &[ + mesh_llm::mesh_status_filter(), + mesh_llm::relay_membership_filter(), + ], + ) + .await?; Ok(pick_serve_target_for_model( mesh_llm::availability_from_events(events).serve_targets, model_id, @@ -265,134 +287,70 @@ pub(crate) async fn resolve_mesh_bootstrap_target( } /// Pure target-selection used by `resolve_mesh_bootstrap_target`: the first -/// gossiped serve target that hosts `model_id`. Returns the full target so the -/// caller has the reporter pubkey (to address the paired connect-request) as -/// well as the dial pointer. Split out so the matching rule is unit-testable -/// without a relay round-trip. +/// gossiped serve target that hosts `model_id`. Split out so the matching rule +/// is unit-testable without a relay round-trip. fn pick_serve_target_for_model( targets: Vec, model_id: &str, ) -> Option { + // "auto" delegates model choice to the mesh router (mesh-llm's + // auto-route path): any live serve target is a valid bootstrap peer. + if model_id == mesh_llm::AUTO_MODEL_ID { + return targets.into_iter().next(); + } + fn canonical_model_id(value: &str) -> String { + value.trim().replace("@main", "") + } + let requested = canonical_model_id(model_id); targets .into_iter() - .find(|target| target.model_id == model_id) + .find(|target| canonical_model_id(&target.model_id) == requested) } /// Decide whether a relay-mesh agent may start, and bring up its local mesh /// client when needed. /// -/// Fresh create (`allow_fresh_create_start`) has just run the client-start flow -/// from the dialog, so it spawns as-is. For a saved/manual start the serve -/// target's dial pointer was never persisted (it is live discovery state), so -/// re-resolve a current bootstrap target from the relay's gossiped targets, -/// bring up the local client node, then publish a paired connect-request -/// (kind:24621) through the runtime coordinator so the peer dials back — the -/// hole-punch needs *both* ends dialing. This is the fix for saved agents -/// flaking on restart: before, saved-start did a one-sided dial and never told -/// the peer to dial back. The two failure modes get distinct, actionable copy: +/// Every start follows the same backend-owned path. If a local runtime exists, +/// wait until its inference router is actually ready. Otherwise re-resolve a +/// current bootstrap target from the members' client-signed discovery notes, +/// then bring up the local MeshLLM client. The endpoint contains MeshLLM's +/// encrypted iroh relay addresses, so no Buzz relay connection coordination is +/// required. The two failure modes get distinct, actionable copy: /// a relay query failure ("could not refresh targets") is not the same as a /// relay that answered with no live target for this model ("peer offline"). /// Non relay-mesh records are a no-op. pub(crate) async fn ensure_relay_mesh_for_record( app: &AppHandle, record: &crate::managed_agents::ManagedAgentRecord, - allow_fresh_create_start: bool, + _allow_fresh_create_start: bool, ) -> Result<(), String> { - if allow_fresh_create_start { - return Ok(()); - } let state = app.state::(); let Some(model_id) = crate::managed_agents::relay_mesh_model_id(record) else { return Ok(()); }; + // A local serve/client runtime already owns the OpenAI ingress and its + // router can resolve both `auto` and explicit remote models. Do not require + // a separate relay-advertised target in that case. + if state.mesh_llm_runtime.lock().await.is_some() { + return wait_for_mesh_inference(&model_id).await; + } let target = match resolve_mesh_bootstrap_target(&state, &model_id).await { Ok(Some(target)) => target, Ok(None) => { - return Err(format!( - "relay mesh agents cannot be started from saved state because no live serve target is available for this model. Start serving on a mesh peer, or create a new agent with Run on relay mesh selected to refresh the target for {RELAY_MESH_API_BASE_URL}." - )); + return Err( + "Buzz shared compute cannot start because no live member is serving this model. Start serving it on a member, then try again." + .to_string(), + ); } Err(error) => { return Err(format!( - "could not refresh relay mesh serve targets to start this agent: {error}" + "could not refresh Buzz shared compute serving members: {error}" )); } }; - // Bring up the local client node (and dial the peer). Its status carries - // our own invite token — the addr we advertise to the peer in the 24621. - let status = - ensure_client_node_for_model(&state, &model_id, Some(target.endpoint_addr.clone())).await?; - - // Publish the paired connect-request so the peer dials *us* back. Needs the - // target's reporter pubkey (whom to address) and our invite token (where to - // dial). If either is missing we have already done the one-sided dial above - // — no worse than the old behavior — so degrade rather than fail the start. - if let (Some(target_pubkey), Some(self_addr)) = ( - normalize_pubkey(target.reporter_pubkey.as_deref()), - status.invite_token.as_deref(), - ) { - if target_pubkey != workspace_pubkey(&state)? { - if let Err(error) = crate::mesh_llm::start_client( - app, - crate::mesh_llm::RelayMeshConnectRequest { - target_pubkey: &target_pubkey, - peer_endpoint_addr: &target.endpoint_addr, - self_endpoint_addr: self_addr, - peer_endpoint_id: target.endpoint_id.as_deref(), - self_endpoint_id: status.endpoint_id.as_deref(), - }, - ) - .await - { - // Non-fatal: the one-sided dial may still punch on a favorable NAT. - // Surface the reason without blocking the agent's spawn. - eprintln!("buzz-mesh: saved-start connect-request failed: {error}"); - } - } - } - Ok(()) -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MeshDialEndpointRequest { - pub endpoint_addr: String, -} - -#[tauri::command] -pub async fn mesh_dial_endpoint_addr( - state: State<'_, AppState>, - request: MeshDialEndpointRequest, -) -> CmdResult { - let endpoint_addr = request.endpoint_addr.trim(); - if endpoint_addr.is_empty() { - return Err("endpointAddr is required".to_string()); - } - let runtime = state.mesh_llm_runtime.lock().await; - let Some(runtime) = runtime.as_ref() else { - return Err("mesh node is not running".to_string()); - }; - runtime - .dial_endpoint_addr(endpoint_addr) - .await - .map_err(|error| format!("mesh dial failed: {error}"))?; - runtime.status().await.map_err(|error| error.to_string()) -} - -#[tauri::command] -pub async fn mesh_status_report_payload( - state: State<'_, AppState>, -) -> CmdResult> { - let runtime = state.mesh_llm_runtime.lock().await; - match runtime.as_ref() { - Some(runtime) => runtime - .status_report_payload() - .await - .map(Some) - .map_err(|error| error.to_string()), - None => Ok(None), - } + ensure_client_node_for_model(&state, &model_id, Some(target.endpoint_addr)).await?; + wait_for_mesh_inference(&model_id).await } #[tauri::command] @@ -404,6 +362,14 @@ pub async fn mesh_stop_node( if let Some(runtime) = runtime { runtime.stop().await.map_err(|error| error.to_string())?; } + save_mesh_sharing_config( + &app, + &MeshSharingConfig { + enabled: false, + model_id: String::new(), + max_vram_gb: None, + }, + )?; mesh_llm::publish_stopped_status_once(&app, "stop").await; Ok(mesh_llm::stopped_status()) } @@ -431,11 +397,15 @@ pub async fn mesh_installed_models( Ok(Vec::new()) } +/// Hardware-aware curated model catalog for the Share-compute picker: the +/// machine's AI memory, a recommended best fit, and every catalog model +/// ranked by fit with installed-state flags. Runs the hardware survey + +/// HF-cache scan off the async runtime (both do blocking I/O). #[tauri::command] -pub fn mesh_agent_preset( - request: mesh_llm::MeshAgentPresetRequest, -) -> CmdResult { - mesh_llm::agent_preset(request) +pub async fn mesh_model_catalog() -> CmdResult { + tokio::task::spawn_blocking(mesh_llm::model_catalog) + .await + .map_err(|error| format!("mesh catalog task failed: {error}")) } #[cfg(all(test, feature = "mesh-llm"))] @@ -450,7 +420,6 @@ mod tests { endpoint_addr: endpoint_addr.to_string(), node_name: None, capacity: None, - reporter_pubkey: None, endpoint_id: None, device_id: None, device_name: None, @@ -464,14 +433,39 @@ mod tests { target("model-b", "addr-b1"), target("model-b", "addr-b2"), ]; - // Matches by model id, returns the first such target (full struct, so - // the caller has the reporter pubkey as well as the dial pointer). + // Matches by model id and returns the first such target. assert_eq!( pick_serve_target_for_model(targets, "model-b").map(|t| t.endpoint_addr), Some("addr-b1".to_string()) ); } + #[test] + fn pick_serve_target_normalizes_main_revision() { + let targets = vec![target("org/model@main:q4", "addr")]; + assert_eq!( + pick_serve_target_for_model(targets, "org/model:q4").map(|target| target.endpoint_addr), + Some("addr".to_string()) + ); + } + + #[test] + fn pick_serve_target_auto_takes_any_live_target() { + let targets = vec![target("model-a", "addr-a"), target("model-b", "addr-b")]; + // "auto" delegates model choice to the mesh router; any live target + // is a valid bootstrap peer (first one wins). + assert_eq!( + pick_serve_target_for_model(targets, crate::mesh_llm::AUTO_MODEL_ID) + .map(|t| t.endpoint_addr), + Some("addr-a".to_string()) + ); + // But auto with zero live targets still falls closed. + assert_eq!( + pick_serve_target_for_model(Vec::new(), crate::mesh_llm::AUTO_MODEL_ID), + None + ); + } + #[test] fn pick_serve_target_none_when_model_not_hosted() { let targets = vec![target("model-a", "addr-a")]; @@ -492,7 +486,7 @@ mod tests { /// /// Before this change, `ensure_client_node_for_model` hard-errored whenever /// the running runtime was in `Serve` mode ("stop sharing before using - /// relay mesh as a client"). That forbade the exact thing a user should be + /// Buzz shared compute as a client"). That forbade exactly what a user should be /// able to do: host model A while pointing an agent at a different model B /// through the same `9337` ingress. /// @@ -505,56 +499,74 @@ mod tests { /// Hardware-gated (`#[ignore]`): loads a real model. Run with: /// cargo test -p buzz-desktop --features mesh-llm \ /// ensure_serve_runtime_serves_other_model -- --ignored --nocapture - #[tokio::test] + #[test] #[ignore = "loads a real model; run manually with --ignored"] - async fn ensure_serve_runtime_serves_other_model() { - const HOSTED_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M"; - const OTHER_MODEL: &str = "some/other-model-not-hosted-locally:Q4_K_M"; + fn ensure_serve_runtime_serves_other_model() { + std::thread::Builder::new() + .name("mesh-hardware-acceptance".to_string()) + .stack_size(mesh_llm::MESH_WORKER_STACK_SIZE) + .spawn(|| { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_stack_size(mesh_llm::MESH_WORKER_STACK_SIZE) + .enable_all() + .build() + .expect("build mesh acceptance runtime"); + runtime.block_on(async { + const HOSTED_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M"; + const OTHER_MODEL: &str = "some/other-model-not-hosted-locally:Q4_K_M"; - let state = build_app_state(); + let state = build_app_state(); - // Start a serve runtime hosting HOSTED_MODEL — this is the "Share - // compute" path. - let serve = mesh_llm::DesktopMeshRuntime::start(mesh_llm::StartMeshNodeRequest { - mode: mesh_llm::MeshNodeMode::Serve, - model_id: Some(HOSTED_MODEL.to_string()), - max_vram_gb: None, - join_token: None, - }) - .await - .expect("serve runtime should start"); + // Start a serve runtime hosting HOSTED_MODEL — this is the "Share + // compute" path. + let serve = + mesh_llm::DesktopMeshRuntime::start(mesh_llm::StartMeshNodeRequest { + mode: mesh_llm::MeshNodeMode::Serve, + model_id: Some(HOSTED_MODEL.to_string()), + max_vram_gb: None, + join_token: None, + trusted_owner_ids: None, + }) + .await + .expect("serve runtime should start"); - let serve_status = serve.status().await.expect("serve status"); - let serve_base = serve_status.api_base_url.clone(); - assert_eq!(serve_status.mode, Some(mesh_llm::MeshNodeMode::Serve)); + let serve_status = serve.status().await.expect("serve status"); + let serve_base = serve_status.api_base_url.clone(); + assert_eq!(serve_status.mode, Some(mesh_llm::MeshNodeMode::Serve)); - { - let mut runtime = state.mesh_llm_runtime.lock().await; - *runtime = Some(serve); - } + { + let mut runtime = state.mesh_llm_runtime.lock().await; + *runtime = Some(serve); + } - // Preflight for a DIFFERENT model with no explicit target. Old code: - // Err(...sharing compute...). New code: reuse the running ingress. - let status = ensure_client_node_for_model(&state, OTHER_MODEL, None) - .await - .expect("serve runtime must not reject a different-model preflight"); + // Preflight for a DIFFERENT model with no explicit target. Old code: + // Err(...sharing compute...). New code: reuse the running ingress. + let status = ensure_client_node_for_model(&state, OTHER_MODEL, None) + .await + .expect("serve runtime must not reject a different-model preflight"); - // It returns the SAME running node — agents keep using A's 9337, and - // the router decides routability for OTHER_MODEL per request. - assert_eq!( - status.mode, - Some(mesh_llm::MeshNodeMode::Serve), - "preflight should reuse the existing serve runtime, not spin up a client" - ); - assert_eq!( - status.api_base_url, serve_base, - "agent must be pointed at the existing serve node's ingress" - ); + // It returns the SAME running node — agents keep using A's 9337, and + // the router decides routability for OTHER_MODEL per request. + assert_eq!( + status.mode, + Some(mesh_llm::MeshNodeMode::Serve), + "preflight should reuse the existing serve runtime, not spin up a client" + ); + assert_eq!( + status.api_base_url, serve_base, + "agent must be pointed at the existing serve node's ingress" + ); - // Clean up the runtime. - let taken = state.mesh_llm_runtime.lock().await.take(); - if let Some(runtime) = taken { - let _ = runtime.stop().await; - } + // Clean up the runtime. + let taken = state.mesh_llm_runtime.lock().await.take(); + if let Some(runtime) = taken { + let _ = runtime.stop().await; + } + }); + }) + .expect("spawn mesh acceptance thread") + .join() + .expect("mesh acceptance thread panicked"); } } diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index af30c3d8b9..5ee19eca4e 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,5 +1,8 @@ mod agent_config; mod agent_discovery; +mod agent_logs; +mod agent_metric_archive; +mod agent_model_process; mod agent_models; mod agent_providers; mod agent_settings; @@ -11,17 +14,19 @@ mod channels; mod dms; mod engrams; mod export_util; +mod global_agent_config; mod identity; mod identity_archive; mod legacy_storage; mod link_preview; -mod media; +pub(crate) mod media; mod media_download; mod media_transcode; #[cfg(feature = "mesh-llm")] -mod mesh_llm; +pub(crate) mod mesh_llm; mod messages; mod notifications; +mod observer_archive; pub mod pairing; mod personas; mod prevent_sleep; @@ -34,13 +39,17 @@ mod project_terminal; mod relay_members; mod relay_reconnect; mod social; +mod team_snapshot; mod teams; mod updater; +mod window_vibrancy; mod workflows; mod workspace; pub use agent_config::*; pub use agent_discovery::*; +pub use agent_logs::*; +pub use agent_metric_archive::*; pub use agent_models::*; pub use agent_providers::*; pub use agent_settings::*; @@ -51,6 +60,7 @@ pub use channel_window::*; pub use channels::*; pub use dms::*; pub use engrams::*; +pub use global_agent_config::*; pub use identity::*; pub use identity_archive::*; pub use legacy_storage::*; @@ -61,6 +71,7 @@ pub use media_download::*; pub use mesh_llm::*; pub use messages::*; pub use notifications::*; +pub use observer_archive::*; pub use pairing::*; pub use personas::*; pub use prevent_sleep::*; @@ -71,7 +82,9 @@ pub use project_terminal::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; +pub use team_snapshot::*; pub use teams::*; pub use updater::*; +pub use window_vibrancy::*; pub use workflows::*; pub use workspace::*; diff --git a/desktop/src-tauri/src/commands/observer_archive.rs b/desktop/src-tauri/src/commands/observer_archive.rs new file mode 100644 index 0000000000..f7286b0180 --- /dev/null +++ b/desktop/src-tauri/src/commands/observer_archive.rs @@ -0,0 +1,41 @@ +//! Build-time flag and runtime dev-nest check for observer-feed archive default. +//! +//! `observer_archive_default_enabled()` returns `true` when either: +//! - `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` was set at build time (internal +//! builds bake in the flag via `build.rs`), **or** +//! - the running binary is using the dev nest (`~/.buzz-dev`), which is the +//! case for all dev builds launched with `just staging` or `just dev`. +//! +//! When `true`, the frontend auto-seeds an `owner_p` save subscription for the +//! current identity on first run, so the observer-feed archive is on by default. +//! +//! OSS prod builds (baked flag unset, prod nest `~/.buzz`) return `false` — +//! no auto-seeding; the user opts in manually via the Local Archive settings card. + +/// Returns `true` when observer-feed archive should default to on. +/// +/// True when the build has the internal baked flag set, or when the running +/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this once +/// at startup to decide whether to seed the `owner_p` save subscription. +#[tauri::command] +pub fn observer_archive_default_enabled() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some() + || crate::managed_agents::nest_is_dev() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_observer_archive_default_enabled_returns_bool() { + // The command must return a plain bool without panicking. + // Whether it's true or false depends on the build environment; + // what we assert here is just that the return type is correct and + // the function is callable. + let result = observer_archive_default_enabled(); + // In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT + // baked in), this should be false. + assert!(!result, "expected false in OSS/test build"); + } +} diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 1168b9f1b6..6bddca047c 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -76,15 +76,12 @@ pub async fn start_pairing( } pairing.clear(); - let (nsec, pubkey_hex) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let nsec = keys - .secret_key() - .to_bech32() - .map_err(|e| format!("encode nsec: {e}"))?; - let pubkey = keys.public_key().to_hex(); - (nsec, pubkey) - }; + let keys = state.signing_keys()?; + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| format!("encode nsec: {e}"))?; + let pubkey_hex = keys.public_key().to_hex(); let ws_url = relay_ws_url_with_override(&state); let http_url = relay_api_base_url_with_override(&state); @@ -95,13 +92,16 @@ pub async fn start_pairing( // own NIP-11 declaration of NIP-43 support rather than `auth_required`, // which is also true for plain NIP-42 / NIP-OA relays where the main // relay is reachable. - let qr_relay_url = if probe_relay_supports_nip43(&ws_url).await { - let mut url = url::Url::parse(&ws_url).map_err(|e| format!("invalid relay URL: {e}"))?; - let path = url.path().trim_end_matches('/').to_string(); - url.set_path(&format!("{path}/pair")); - url.to_string() - } else { - ws_url.clone() + let qr_relay_url = match probe_pairing_relay(&ws_url).await { + PairingRelay::Configured(url) => url, + PairingRelay::LegacyPath => { + let mut url = + url::Url::parse(&ws_url).map_err(|e| format!("invalid relay URL: {e}"))?; + let path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{path}/pair")); + url.to_string() + } + PairingRelay::MainRelay => ws_url.clone(), }; let (session, qr_payload) = PairingSession::new_source(qr_relay_url); @@ -416,27 +416,24 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option { serde_json::from_value(arr[2].clone()).ok() } -/// Check the relay's NIP-11 document to determine whether it advertises -/// NIP-43 (relay membership). Returns `true` only if NIP-43 appears in the -/// relay's `supported_nips`. Unreachable relays, malformed responses, and -/// non-`ws(s)://` URLs all return `false`: we'd rather fail loudly against -/// the main relay than misroute pairing to an undeployed `/pair` sidecar. -/// -/// Converts the WebSocket URL to HTTP(S) and fetches `GET /` with -/// `Accept: application/nostr+json` per NIP-11. -/// -/// We test for NIP-43 specifically rather than the broader -/// `limitation.auth_required` flag because the latter is also set on plain -/// NIP-42 / NIP-OA relays, which accept unpaired peers on the main relay -/// and have no `/pair` sidecar. -async fn probe_relay_supports_nip43(relay_url: &str) -> bool { - // Convert ws(s):// to http(s):// for the NIP-11 fetch. +/// Pairing route discovered from the main relay's NIP-11 document. +#[derive(Debug, PartialEq, Eq)] +enum PairingRelay { + Configured(String), + LegacyPath, + MainRelay, +} + +/// Prefer the relay-advertised dedicated pairing URL. The legacy `/pair` +/// convention remains as a compatibility fallback for NIP-43 relays that do +/// not advertise the extension yet. +async fn probe_pairing_relay(relay_url: &str) -> PairingRelay { let http_url = if let Some(rest) = relay_url.strip_prefix("wss://") { format!("https://{rest}") } else if let Some(rest) = relay_url.strip_prefix("ws://") { format!("http://{rest}") } else { - return false; + return PairingRelay::MainRelay; }; let client = reqwest::Client::builder() @@ -450,19 +447,39 @@ async fn probe_relay_supports_nip43(relay_url: &str) -> bool { .send() .await { - Ok(r) => r, - Err(_) => return false, // can't reach relay — assume open + Ok(response) => response, + Err(_) => return PairingRelay::MainRelay, }; let json: serde_json::Value = match resp.json().await { - Ok(v) => v, - Err(_) => return false, + Ok(value) => value, + Err(_) => return PairingRelay::MainRelay, }; - json.get("supported_nips") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().any(|n| n.as_u64() == Some(43))) - .unwrap_or(false) + pairing_relay_from_nip11(&json) +} + +fn pairing_relay_from_nip11(json: &serde_json::Value) -> PairingRelay { + if let Some(value) = json + .get("pairing_relay_url") + .and_then(|value| value.as_str()) + { + if let Ok(url) = url::Url::parse(value) { + if matches!(url.scheme(), "ws" | "wss") && url.host_str().is_some() { + return PairingRelay::Configured(value.to_string()); + } + } + } + + if json + .get("supported_nips") + .and_then(|value| value.as_array()) + .is_some_and(|nips| nips.iter().any(|nip| nip.as_u64() == Some(43))) + { + PairingRelay::LegacyPath + } else { + PairingRelay::MainRelay + } } fn parse_auth_challenge(text: &str) -> Option { @@ -502,3 +519,76 @@ where .await .map_err(|_| "timeout waiting for EOSE".to_string())? } + +#[cfg(test)] +mod pairing_relay_tests { + use super::{pairing_relay_from_nip11, probe_pairing_relay, PairingRelay}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn live_nip11_probe_discovers_configured_pairing_relay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test NIP-11 server"); + let addr = listener.local_addr().expect("test server address"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request"); + let mut request = vec![0; 2048]; + let bytes_read = stream.read(&mut request).await.expect("read request"); + let request = String::from_utf8_lossy(&request[..bytes_read]); + assert!(request.starts_with("GET / HTTP/1.1")); + assert!(request + .to_ascii_lowercase() + .contains("accept: application/nostr+json")); + + let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write response"); + }); + + assert_eq!( + probe_pairing_relay(&format!("ws://{addr}")).await, + PairingRelay::Configured("ws://127.0.0.1:5000".to_string()) + ); + server.await.expect("NIP-11 server task"); + } + + #[test] + fn configured_pairing_relay_takes_precedence_over_legacy_path() { + let document = serde_json::json!({ + "pairing_relay_url": "wss://pairing.buzz.xyz", + "supported_nips": [43] + }); + + assert_eq!( + pairing_relay_from_nip11(&document), + PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()) + ); + } + + #[test] + fn invalid_pairing_relay_url_falls_back_to_legacy_path() { + let document = serde_json::json!({ + "pairing_relay_url": "https://pairing.buzz.xyz", + "supported_nips": [43] + }); + + assert_eq!( + pairing_relay_from_nip11(&document), + PairingRelay::LegacyPath + ); + } + + #[test] + fn document_without_pairing_configuration_uses_main_relay() { + let document = serde_json::json!({ "supported_nips": [1, 11] }); + + assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay); + } +} diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs new file mode 100644 index 0000000000..316af5f72d --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -0,0 +1,202 @@ +//! Tests for cascade-delete filtering in `delete_persona`. +//! +//! `delete_persona` deletes all managed-agent records whose `persona_id` +//! matches the persona being deleted, mirroring the cleanup done by +//! `delete_managed_agent`. These tests verify the `collect_cascade_pubkeys` +//! helper that identifies the agents to cascade-delete, using plain +//! in-memory data structures (no `AppHandle` required). + +use super::{collect_cascade_pubkeys, collect_remote_deployed, commit_cascade_agents}; +use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; +use std::collections::BTreeMap; +use std::collections::HashSet; + +fn make_agent( + pubkey: &str, + persona_id: Option<&str>, + runtime_pid: Option, +) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: pubkey.to_string(), + name: "Test Agent".to_string(), + persona_id: persona_id.map(str::to_string), + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "buzz-agent".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + runtime_pid, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + relay_mesh: None, + auto_restart_on_config_change: false, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + } +} + +const PERSONA_ID: &str = "custom:test-persona"; + +/// Deleting a persona with two linked agents (one running) returns both their +/// pubkeys and leaves unlinked agents out of the cascade set. +#[test] +fn cascade_includes_linked_agents_and_excludes_others() { + let agents = vec![ + make_agent("agent-a", Some(PERSONA_ID), Some(12345)), // running, linked + make_agent("agent-b", Some(PERSONA_ID), None), // stopped, linked + make_agent("agent-c", Some("custom:other"), None), // different persona + make_agent("agent-d", None, None), // no persona + ]; + + let pubkeys = collect_cascade_pubkeys(&agents, PERSONA_ID); + + assert_eq!( + pubkeys.len(), + 2, + "exactly two agents linked to this persona" + ); + assert!( + pubkeys.contains(&"agent-a".to_string()), + "running linked agent included" + ); + assert!( + pubkeys.contains(&"agent-b".to_string()), + "stopped linked agent included" + ); + assert!( + !pubkeys.contains(&"agent-c".to_string()), + "different-persona agent excluded" + ); + assert!( + !pubkeys.contains(&"agent-d".to_string()), + "persona-less agent excluded" + ); +} + +/// Deleting a persona with no linked agents returns an empty list (no cascade). +#[test] +fn cascade_empty_when_no_linked_agents() { + let agents = vec![ + make_agent("agent-x", Some("custom:other"), None), + make_agent("agent-y", None, None), + ]; + + let pubkeys = collect_cascade_pubkeys(&agents, PERSONA_ID); + + assert!(pubkeys.is_empty(), "no agents to cascade-delete"); +} + +/// Cascade targets all agents linked to the persona — not just stopped ones. +/// A running agent (runtime_pid set) must appear in the cascade set so the +/// command can stop it before removing the record. +#[test] +fn cascade_includes_running_agent() { + let agents = vec![make_agent("running-agent", Some(PERSONA_ID), Some(99999))]; + + let pubkeys = collect_cascade_pubkeys(&agents, PERSONA_ID); + + assert_eq!(pubkeys, vec!["running-agent".to_string()]); +} + +/// A failing agent-store save in Phase 3 must be retry-safe: the error +/// propagates before any keyring deletion or tombstone at the call site +/// (by construction — those side effects appear after the `?` in +/// `delete_persona`). Persona records and agent records are therefore +/// untouched on disk, so the command can be retried with no cleanup. +#[test] +fn failing_save_is_retry_safe() { + let mut agents = vec![ + make_agent("pk-a", Some(PERSONA_ID), None), + make_agent("pk-b", Some(PERSONA_ID), None), + make_agent("pk-c", Some("custom:other"), None), + ]; + let cascade: HashSet = ["pk-a".to_string(), "pk-b".to_string()].into(); + + let result = commit_cascade_agents(&mut agents, &cascade, |_| { + Err("simulated disk failure".to_string()) + }); + + assert!( + result.is_err(), + "commit must propagate the save error so callers can react" + ); + // By construction: commit_cascade_agents returns Err before reaching the + // keyring deletions and tombstones at the delete_persona call site. + // Retrying delete_persona re-runs the full cascade cleanly from scratch. +} + +/// A provider-deployed cascade target (non-local backend with a live +/// `backend_agent_id`) must be detected by the pre-flight so `delete_persona` +/// refuses the cascade before any destructive work. Local agents and +/// never-deployed provider agents must not block. +#[test] +fn remote_deployed_cascade_target_blocks_delete() { + let mut deployed = make_agent("pk-deployed", Some(PERSONA_ID), None); + deployed.name = "Deployed Agent".to_string(); + deployed.backend = BackendKind::Provider { + id: "blox".to_string(), + config: serde_json::Value::Null, + }; + deployed.backend_agent_id = Some("backend-1".to_string()); + + // Provider backend but never deployed (no backend_agent_id) — not a blocker. + let mut undeployed = make_agent("pk-undeployed", Some(PERSONA_ID), None); + undeployed.backend = BackendKind::Provider { + id: "blox".to_string(), + config: serde_json::Value::Null, + }; + + let agents = vec![ + make_agent("pk-local", Some(PERSONA_ID), None), + deployed, + undeployed, + ]; + let cascade: HashSet = collect_cascade_pubkeys(&agents, PERSONA_ID) + .into_iter() + .collect(); + assert_eq!(cascade.len(), 3, "all three agents are cascade targets"); + + let blockers = collect_remote_deployed(&agents, &cascade); + + assert_eq!( + blockers, + vec!["Deployed Agent".to_string()], + "only the deployed provider agent blocks the cascade" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index aa77b71bf5..6c7012e7e6 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -8,8 +8,8 @@ const UUID: &str = "11111111-2222-3333-4444-555555555555"; /// A local in-app persona: `source_team_persona_slug` is None, so its d-tag /// IS its UUID id. Carries env_vars + source_team that must survive a patch. -fn local_in_app() -> PersonaRecord { - PersonaRecord { +fn local_in_app() -> AgentDefinition { + AgentDefinition { id: UUID.to_string(), display_name: "Local".to_string(), avatar_url: None, @@ -23,6 +23,9 @@ fn local_in_app() -> PersonaRecord { source_team: Some("team-1".to_string()), source_team_persona_slug: None, env_vars: BTreeMap::from([("API_KEY".to_string(), "secret".to_string())]), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), } @@ -30,8 +33,8 @@ fn local_in_app() -> PersonaRecord { /// An inbound persona as `persona_from_event` would produce it: id = d-tag, /// slug = Some(d-tag), empty env_vars, source_team None. -fn inbound_for(d_tag: &str, display_name: &str) -> PersonaRecord { - PersonaRecord { +fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { + AgentDefinition { id: d_tag.to_string(), display_name: display_name.to_string(), avatar_url: Some("https://example.com/a.png".to_string()), @@ -45,6 +48,9 @@ fn inbound_for(d_tag: &str, display_name: &str) -> PersonaRecord { source_team: None, source_team_persona_slug: Some(d_tag.to_string()), env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, created_at: "2025-06-01T00:00:00Z".to_string(), updated_at: "2025-06-01T00:00:00Z".to_string(), } @@ -69,6 +75,34 @@ fn in_app_persona_matches_existing_uuid_and_patches() { assert_eq!(p.created_at, "2025-01-01T00:00:00Z"); } +#[test] +fn inbound_quad_edit_applies_to_existing_matched_record() { + // B5 quad activation: a remote quad edit must land on the MATCH branch, + // not just the insert branch — otherwise device B keeps its stale quad + // and its next reconcile republishes it over device A's edit, and the + // two devices never converge (permanent ping-pong). + let mut local = local_in_app(); + local.respond_to = Some("owner-only".to_string()); + local.parallelism = Some(2); + let mut personas = vec![local]; + + let mut inbound = inbound_for(UUID, "Remote"); + inbound.respond_to = Some("allowlist".to_string()); + inbound.respond_to_allowlist = vec!["a".repeat(64)]; + inbound.parallelism = Some(8); + apply_inbound_persona(&mut personas, inbound); + + assert_eq!(personas.len(), 1, "no duplicate row"); + let p = &personas[0]; + assert_eq!(p.respond_to, Some("allowlist".to_string())); + assert_eq!(p.respond_to_allowlist, vec!["a".repeat(64)]); + assert_eq!(p.parallelism, Some(8)); + // A quad-absent inbound also applies (clears), same as prompt/model. + apply_inbound_persona(&mut personas, inbound_for(UUID, "Remote")); + assert_eq!(personas[0].respond_to, None); + assert_eq!(personas[0].parallelism, None); +} + #[test] fn re_received_in_app_persona_is_idempotent_no_duplicate() { let mut personas = vec![local_in_app()]; @@ -141,9 +175,9 @@ fn local_agent() -> ManagedAgentRecord { model: Some("local-model".to_string()), provider: Some("local-provider".to_string()), persona_source_version: Some("local-hash".to_string()), - mcp_toolsets: Some("local".to_string()), env_vars: BTreeMap::from([("API_KEY".to_string(), "localsecret".to_string())]), start_on_app_launch: true, + auto_restart_on_config_change: true, runtime_pid: Some(1234), backend: crate::managed_agents::BackendKind::Provider { id: "buzz-backend".to_string(), @@ -151,6 +185,7 @@ fn local_agent() -> ManagedAgentRecord { }, backend_agent_id: Some("local-remote-id".to_string()), provider_binary_path: Some("/local/bin".to_string()), + team_id: None, persona_team_dir: None, persona_name_in_team: None, created_at: "2025-01-01T00:00:00Z".to_string(), @@ -159,8 +194,20 @@ fn local_agent() -> ManagedAgentRecord { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: crate::managed_agents::RespondTo::OwnerOnly, respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, relay_mesh: None, } } @@ -176,7 +223,6 @@ fn foreign_agent_event_with_secrets(d_tag: &str) -> nostr::Event { "system_prompt": "remote prompt", "model": "remote-model", "provider": "remote-provider", - "mcp_toolsets": "remote", "persona_source_version": "remote-hash", "parallelism": 99, "respond_to": "anyone", @@ -259,14 +305,60 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() { ] { assert!(!json.contains(needle), "injected value leaked: {needle}"); } - // Projected fields ARE updated from the inbound event. + // Instance-level projected fields ARE updated from the inbound event. assert_eq!(a.name, "Remote Agent"); - assert_eq!(a.system_prompt, Some("remote prompt".to_string())); - assert_eq!(a.model, Some("remote-model".to_string())); - assert_eq!(a.provider, Some("remote-provider".to_string())); assert_eq!(a.parallelism, 99); assert_eq!(a.respond_to, crate::managed_agents::RespondTo::Anyone); assert_eq!(a.respond_to_allowlist, vec!["deadbeef".to_string()]); + // Definition-linked inbound (persona_id present): the definition quad is + // NOT applied — those fields resolve through the kind:30175 definition, + // and absent-on-the-wire must never clear a local snapshot. + assert_eq!( + a.system_prompt, + Some("local prompt".to_string()), + "linked inbound must not touch the local prompt snapshot" + ); +} + +/// Definition-less inbound (persona_id absent) still applies the definition +/// quad unconditionally — the record is its own definition and the wire is +/// its sync channel. +#[test] +fn inbound_definition_less_agent_applies_quad() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + // Same wire shape as the hostile fixture, minus persona_id — a + // definition-less instance syncing its own definition fields. + let content = serde_json::json!({ + "name": "Remote Agent", + "system_prompt": "remote prompt", + "model": "remote-model", + "provider": "remote-provider", + "persona_source_version": "remote-version", + "parallelism": 99, + "respond_to": "anyone", + "respond_to_allowlist": ["deadbeef"], + }); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(30177), content.to_string()) + .tags(vec![Tag::parse(["d", AGENT_PUBKEY]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + + let content = + crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap(); + let mut agents = vec![local_agent()]; + apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content); + + let a = &agents[0]; + assert_eq!(a.persona_id, None); + assert_eq!(a.system_prompt, Some("remote prompt".to_string())); + assert_eq!(a.model, Some("remote-model".to_string())); + assert_eq!(a.provider, Some("remote-provider".to_string())); + assert_eq!( + a.persona_source_version, + Some("remote-version".to_string()), + "all four quad fields must apply on a definition-less sync" + ); } #[test] @@ -294,6 +386,7 @@ fn local_team() -> TeamRecord { id: TEAM_ID.to_string(), name: "Local Team".to_string(), description: Some("local desc".to_string()), + instructions: None, persona_ids: vec!["p-local".to_string()], is_builtin: false, source_dir: Some(std::path::PathBuf::from("/local/team/dir")), @@ -309,6 +402,7 @@ fn team_content(name: &str) -> TeamEventContent { TeamEventContent { name: name.to_string(), description: Some("remote desc".to_string()), + instructions: Some("remote instructions".to_string()), persona_ids: vec!["p-remote-1".to_string(), "p-remote-2".to_string()], } } @@ -327,6 +421,7 @@ fn inbound_team_match_patches_shared_preserves_local() { // Shared fields overwritten. assert_eq!(t.name, "Renamed Team"); assert_eq!(t.description, Some("remote desc".to_string())); + assert_eq!(t.instructions, Some("remote instructions".to_string())); assert_eq!( t.persona_ids, vec!["p-remote-1".to_string(), "p-remote-2".to_string()] @@ -364,35 +459,54 @@ fn inbound_team_no_match_inserts_idempotently() { // ── Tombstone (kind:5) consume ──────────────────────────────────────────── fn deletion_event(coord: &str) -> nostr::Event { - use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + deletion_event_with_keys(coord, &nostr::Keys::generate()) +} + +fn deletion_event_with_keys(coord: &str, keys: &nostr::Keys) -> nostr::Event { + use nostr::{EventBuilder, JsonUtil, Kind, Tag}; let event = EventBuilder::new(Kind::Custom(5), "") .tags(vec![Tag::parse(["a", coord]).unwrap()]) - .sign_with_keys(&Keys::generate()) + .sign_with_keys(keys) .unwrap(); nostr::Event::from_json(event.as_json()).unwrap() } +/// A deletion event whose coordinate owner IS its signer — the only shape +/// `parse_deletion_coordinate` accepts since the owner check landed. +fn owned_deletion_event(kind: u32, d_tag: &str) -> nostr::Event { + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + deletion_event_with_keys(&format!("{kind}:{owner}:{d_tag}"), &keys) +} + #[test] fn parse_deletion_coordinate_extracts_kind_and_d_tag() { - let owner = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; // Persona / team / agent coordinates all route by their leading kind. - let p = deletion_event(&format!("30175:{owner}:my-persona")); + let p = owned_deletion_event(30175, "my-persona"); assert_eq!( parse_deletion_coordinate(&p), Some((30175, "my-persona".to_string())) ); - let a = deletion_event(&format!("30177:{owner}:agentpubkeyhex")); + let a = owned_deletion_event(30177, "agentpubkeyhex"); assert_eq!( parse_deletion_coordinate(&a), Some((30177, "agentpubkeyhex".to_string())) ); } +#[test] +fn parse_deletion_coordinate_rejects_foreign_owner() { + // A validly signed kind:5 naming ANOTHER owner's coordinate must no-op: + // NIP-09 scopes deletion to the record's own author. + let foreign_owner = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + let forged = deletion_event(&format!("30175:{foreign_owner}:my-persona")); + assert_eq!(parse_deletion_coordinate(&forged), None); +} + #[test] fn parse_deletion_coordinate_handles_colon_in_d_tag_and_rejects_malformed() { - let owner = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; // A d-tag containing ':' keeps its remainder intact (splitn(3)). - let weird = deletion_event(&format!("30176:{owner}:a:b:c")); + let weird = owned_deletion_event(30176, "a:b:c"); assert_eq!( parse_deletion_coordinate(&weird), Some((30176, "a:b:c".to_string())) @@ -429,3 +543,45 @@ fn tombstone_removal_predicates_match_apply_fn_keys() { agents.retain(|r| r.pubkey != AGENT_PUBKEY); assert!(agents.is_empty(), "agent removed by pubkey"); } + +// ── Inbound signature gate ────────────────────────────────────────────────── + +#[test] +fn inbound_gate_rejects_tampered_event() { + use nostr::JsonUtil; + // A validly signed event whose content was altered post-signing: the + // pubkey is real, the sig no longer covers the bytes. Must die at the + // gate before any store logic runs. + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30175), "{}") + .tags(vec![nostr::Tag::parse(["d", "victim-slug"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let tampered = event.as_json().replace( + "\"content\":\"{}\"", + "\"content\":\"{\\\"system_prompt\\\":\\\"pwned\\\"}\"", + ); + assert_ne!( + tampered, + event.as_json(), + "string replace must have taken effect — if this fails the test is testing an un-tampered event" + ); + + let err = parse_verified_inbound_event(&tampered).unwrap_err(); + assert!( + err.contains("signature"), + "tampered event must fail the signature gate: {err}" + ); +} + +#[test] +fn inbound_gate_accepts_validly_signed_event() { + use nostr::JsonUtil; + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30175), "{}") + .tags(vec![nostr::Tag::parse(["d", "slug"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let parsed = parse_verified_inbound_event(&event.as_json()).unwrap(); + assert_eq!(parsed.pubkey, keys.public_key()); +} diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index b140050f4a..19febe0d0e 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -1,18 +1,16 @@ -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager}; use uuid::Uuid; -use super::export_util::save_json_with_dialog; use crate::{ app_state::AppState, managed_agents::{ - agent_events::ManagedAgentEventContent, effective_agent_command, encode_persona_json, - load_managed_agents, load_personas, load_teams, managed_agent_avatar_url, - parse_json_persona, parse_md_persona, parse_png_persona, parse_zip_personas, - persona_events::persona_d_tag, save_managed_agents, save_personas, - team_events::TeamEventContent, team_persona_key, try_regenerate_nest, - validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest, - ManagedAgentRecord, ParsePersonaFilesResult, PersonaRecord, TeamRecord, - UpdatePersonaRequest, + agent_events::ManagedAgentEventContent, apply_persona_behavior, current_instance_id, + delete_agent_key, effective_agent_command, load_managed_agents, load_personas, load_teams, + managed_agent_avatar_url, persona_events::persona_d_tag, save_managed_agents, + save_personas, stop_managed_agent_process, sync_managed_agent_processes, + team_events::TeamEventContent, try_regenerate_nest, validate_persona_activation_change, + validate_persona_deletion, AgentDefinition, CreatePersonaRequest, ManagedAgentRecord, + TeamRecord, UpdatePersonaRequest, }, util::now_iso, }; @@ -32,126 +30,12 @@ fn trim_optional(value: Option) -> Option { }) } -/// Retain a freshly authored persona event in the local store, flagged for -/// relay sync. Called inside a command's `managed_agents_store_lock`-held body -/// after `save_personas`; the background flush loop publishes it out-of-band. -/// -/// The event is signed with the owner keys at call time, so its `created_at` -/// is `now` — newer than any prior retained row, clearing the upsert's -/// newer-or-equal guard. `pending_sync = 1` enqueues it for the flush loop, -/// which is the sole publisher. Best-effort: a failure here is logged and -/// swallowed so a retention hiccup never blocks the disk-authoritative write. -/// -/// Unlike `retain_managed_agent_pending`, this has no projection-equality -/// short-circuit: personas have no start/stop runtime churn, so a republish -/// only happens on a genuine create/update/delete user edit (`set_persona_active` -/// does not retain, so the local-only `is_active` toggle never republishes, and -/// a byte-identical user-save republish is harmlessly NIP-33-replaced). The -/// guard is intentionally omitted. -fn retain_persona_pending(app: &AppHandle, state: &AppState, persona: &PersonaRecord) { - use crate::managed_agents::{ - managed_agents_base_dir, - persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, - }; - use buzz_core_pkg::kind::KIND_PERSONA; - use nostr::JsonUtil; - - let result = (|| -> Result<(), String> { - let d_tag = persona_d_tag(persona); - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - // Monotonic created_at: read the retained head for this coordinate - // and bump past it (NIP-AP step 3) so a same-second edit supersedes. - let prior = - get_retained_event(&conn, KIND_PERSONA, &keys.public_key().to_hex(), &d_tag)? - .map(|row| row.created_at); - let event = build_persona_event(persona)? - .custom_created_at(monotonic_created_at(prior)) - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign persona event: {e}"))?; - (keys.public_key().to_hex(), event) - }; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_PERSONA, - pubkey, - d_tag, - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: persona-retain: {e}"); - } -} - -/// Purge a deleted persona's pending row and enqueue a NIP-09 tombstone, both -/// inside the `managed_agents_store_lock`-held delete body. -/// -/// PURGE IN: `delete_retained_event` removes the persona's `(30175, pubkey, -/// d_tag)` row. Running it under the same lock that serializes `retain_event` -/// closes the same-second resurrect race — a concurrent edit can't re-insert a -/// pending persona row after the tombstone is queued. -/// -/// PUBLISH OUT: the kind:5 tombstone is retained at its own coordinate `(5, -/// pubkey, d_tag)` (distinct from the purged persona row) with `pending_sync = -/// 1`; the flush loop publishes it. Best-effort: a failure is logged and -/// swallowed so a retention hiccup never blocks the disk-authoritative delete. -pub(super) fn tombstone_persona_pending(app: &AppHandle, state: &AppState, d_tag: &str) { - use crate::managed_agents::{ - managed_agents_base_dir, - persona_events::build_persona_delete, - retention::{ - delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, - RetainedEvent, - }, - }; - use buzz_core_pkg::kind::KIND_PERSONA; - use nostr::JsonUtil; - - const KIND_DELETE: u32 = 5; - - let result = (|| -> Result<(), String> { - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - let event = build_persona_delete(d_tag, &pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; - (pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - // Purge the persona row first so an unpublished edit can never resurrect - // it after the tombstone publishes. - delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_DELETE, - pubkey, - // Key by the target coordinate so cross-kind d-tag tombstones - // occupy distinct rows (F2c). - d_tag: tombstone_retention_d_tag(KIND_PERSONA, d_tag), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: persona-tombstone: {e}"); - } -} +mod pending; +pub(in crate::commands) use pending::retain_persona_pending; +pub(super) use pending::tombstone_persona_pending; #[tauri::command] -pub async fn list_personas(app: AppHandle) -> Result, String> { +pub async fn list_personas(app: AppHandle) -> Result, String> { use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -169,7 +53,7 @@ pub async fn list_personas(app: AppHandle) -> Result, String> pub async fn create_persona( input: CreatePersonaRequest, app: AppHandle, -) -> Result { +) -> Result { use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -193,7 +77,7 @@ pub async fn create_persona( .filter(|s| !s.is_empty()) .collect(); crate::managed_agents::validate_user_env_keys(&input.env_vars)?; - let persona = PersonaRecord { + let mut persona = AgentDefinition { id: Uuid::new_v4().to_string(), display_name, avatar_url, @@ -207,9 +91,13 @@ pub async fn create_persona( source_team: None, source_team_persona_slug: None, env_vars: input.env_vars, + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, created_at: now.clone(), updated_at: now, }; + apply_persona_behavior(&mut persona, input.behavior)?; personas.push(persona.clone()); save_personas(&app, &personas)?; retain_persona_pending(&app, &state, &persona); @@ -220,11 +108,46 @@ pub async fn create_persona( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Return value of the `update_persona` command. Uses flatten so all +/// `AgentDefinition` fields appear at the top level of the JSON response — +/// backward-compatible with callers that already destructure a raw persona object. +#[derive(Debug, serde::Serialize)] +pub struct UpdatePersonaResult { + #[serde(flatten)] + persona: AgentDefinition, +} + +/// Propagate a persona definition's display_name rename to linked agent instances. +/// Only instances whose current `name` equals `old_display_name` are updated; +/// pool-named instances (e.g. "Birch", "Compass") keep their individualised name. +/// Updates both `record.name` (relay display name) and `record.display_name`. +/// Returns the pubkeys of the records that were renamed. +fn propagate_persona_name_rename( + records: &mut [ManagedAgentRecord], + persona_id: &str, + old_display_name: &str, + new_display_name: &str, +) -> Vec { + let mut renamed = Vec::new(); + for record in records.iter_mut() { + if record.persona_id.as_deref() != Some(persona_id) { + continue; + } + if record.name != old_display_name { + continue; // pool-named instance — keep its individualised name + } + record.name = new_display_name.to_string(); + record.display_name = Some(new_display_name.to_string()); + renamed.push(record.pubkey.clone()); + } + renamed +} + #[tauri::command] pub async fn update_persona( input: UpdatePersonaRequest, app: AppHandle, -) -> Result { +) -> Result { use tauri::Manager; /// Profile sync params collected under the store lock for async relay publish. @@ -233,13 +156,9 @@ pub async fn update_persona( // Phase 1: synchronous save (persona record + linked agent avatar updates) let (result, profile_sync_params) = tokio::task::spawn_blocking({ let app = app.clone(); - move || -> Result<(PersonaRecord, ProfileSyncParams), String> { + move || -> Result<(AgentDefinition, ProfileSyncParams), String> { let state = app.state::(); let display_name = trim_required(&input.display_name, "Display name")?; - // Do not trim system_prompt: `compose_prompt` appends pack_instructions - // verbatim (including any trailing newline), and write_back_persona_md - // decomposes by suffix-stripping. Trimming would break that exact-suffix - // match for the common case where instructions.md has a trailing newline. let system_prompt = input.system_prompt.clone(); let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); @@ -254,14 +173,12 @@ pub async fn update_persona( let persona = personas .iter_mut() .find(|record| record.id == input.id) - .ok_or_else(|| format!("persona {} not found", input.id))?; + .ok_or_else(|| format!("agent {} not found", input.id))?; - if persona.is_builtin { - return Err("Built-in personas cannot be edited.".to_string()); - } - - // Track whether avatar changed so we can sync linked agents. + // Track what changed so we can propagate to linked agent records. let avatar_changed = persona.avatar_url != avatar_url; + let name_changed = persona.display_name != display_name; + let old_display_name = persona.display_name.clone(); persona.display_name = display_name; persona.avatar_url = avatar_url; @@ -279,62 +196,77 @@ pub async fn update_persona( crate::managed_agents::validate_user_env_keys(&env_vars)?; persona.env_vars = env_vars; } + apply_persona_behavior(persona, input.behavior)?; persona.updated_at = now_iso(); + let result = persona.clone(); save_personas(&app, &personas)?; - let result = personas - .into_iter() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?; - - // For pack-backed personas, also write the edit back to the source - // `.persona.md` so that launch sync (which reads the file) becomes a - // no-op rather than overwriting the record we just saved. - write_back_persona_md(&app, &result); retain_persona_pending(&app, &state, &result); try_regenerate_nest(&app); - // If the avatar changed, propagate to linked agent records and - // collect relay profile sync params for the async phase. - let sync_params: ProfileSyncParams = if avatar_changed { + // If the avatar or display_name changed, propagate to linked agent + // records and collect relay profile sync params for the async phase. + let sync_params: ProfileSyncParams = if avatar_changed || name_changed { let mut records = load_managed_agents(&app)?; let mut params: ProfileSyncParams = Vec::new(); let mut agents_modified = false; let workspace_relay = crate::relay::relay_ws_url_with_override(&state); + // Propagate the display_name rename to instances that still + // carry the old definition display_name (pool-named instances + // keep their individualised name) in one pass; the loop below + // only decides which records need a relay profile sync. + let renamed: Vec = if name_changed { + propagate_persona_name_rename( + &mut records, + &result.id, + &old_display_name, + &result.display_name, + ) + } else { + Vec::new() + }; + for record in records.iter_mut() { if record.persona_id.as_deref() != Some(&result.id) { continue; } - // Update the persisted avatar so reconciliation on next - // start agrees with what we're about to publish. - // When the persona avatar is cleared, fall back to the - // command-default icon so the record never stores `None` - // (which reconcile_agent_profile treats as "un-migrated"). - let effective_cmd = effective_agent_command( - record.persona_id.as_deref(), - std::slice::from_ref(&result), - record.agent_command_override.as_deref(), - ); - record.avatar_url = result - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&effective_cmd)); - agents_modified = true; + let mut record_changed = renamed.contains(&record.pubkey); - if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { - let relay_url = crate::relay::effective_agent_relay_url( - &record.relay_url, - &workspace_relay, + if avatar_changed { + // Update the persisted avatar so reconciliation on next + // start agrees with what we're about to publish. + // When the persona avatar is cleared, fall back to the + // command-default icon so the record never stores `None` + // (which reconcile_agent_profile treats as "un-migrated"). + let effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + std::slice::from_ref(&result), + record.agent_command_override.as_deref(), ); - params.push(( - agent_keys, - relay_url, - record.name.clone(), - record.avatar_url.clone(), - record.auth_tag.clone(), - )); + record.avatar_url = result + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&effective_cmd)); + record_changed = true; + } + + if record_changed { + agents_modified = true; + if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &workspace_relay, + ); + params.push(( + agent_keys, + relay_url, + record.name.clone(), + record.avatar_url.clone(), + record.auth_tag.clone(), + )); + } } } @@ -353,10 +285,10 @@ pub async fn update_persona( .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; - // Phase 2: await relay profile sync for linked agents whose avatar was - // just updated. We await (rather than fire-and-forget) so the frontend - // cache invalidation that follows the mutation settlement sees the fresh - // relay profile. Best-effort — failures are logged, not surfaced. + // Phase 2: await relay profile sync for linked agents whose avatar or + // display_name was just updated. We await (rather than fire-and-forget) + // so the frontend cache invalidation that follows the mutation settlement + // sees the fresh relay profile. Best-effort — failures are logged, not surfaced. if !profile_sync_params.is_empty() { let state = app.state::(); for (agent_keys, relay_url, display_name, avatar_url, auth_tag) in profile_sync_params { @@ -370,68 +302,205 @@ pub async fn update_persona( ) .await { - eprintln!( - "buzz-desktop: relay profile sync failed after persona avatar update: {e}" - ); + eprintln!("buzz-desktop: relay profile sync failed after persona update: {e}"); } } } - Ok(result) + Ok(UpdatePersonaResult { persona: result }) } -mod writeback; -use writeback::write_back_persona_md; - +#[cfg(test)] +mod delete_cascade_tests; #[cfg(test)] mod inbound_tests; +#[cfg(test)] +mod name_propagation_tests; + +/// Return pubkeys of every managed agent whose definition is the given persona. +/// +/// Pure helper used by `delete_persona` to determine which agent records to +/// cascade-delete. Extracted so the filtering logic can be unit-tested without +/// a full Tauri `AppHandle`. +fn collect_cascade_pubkeys(agents: &[ManagedAgentRecord], persona_id: &str) -> Vec { + agents + .iter() + .filter(|a| a.persona_id.as_deref() == Some(persona_id)) + .map(|a| a.pubkey.clone()) + .collect() +} + +/// Names of cascade agents that are provider-deployed: non-local backend with +/// a live `backend_agent_id`. +/// +/// Pure helper used by `delete_persona`'s pre-flight: the cascade is refused +/// while any exist, because deleting the local record would orphan the remote +/// deployment. Mirrors `delete_managed_agent`'s `force_remote_delete` guard. +fn collect_remote_deployed( + agents: &[ManagedAgentRecord], + cascade: &std::collections::HashSet, +) -> Vec { + agents + .iter() + .filter(|a| { + cascade.contains(&a.pubkey) + && a.backend != crate::managed_agents::BackendKind::Local + && a.backend_agent_id.is_some() + }) + .map(|a| a.name.clone()) + .collect() +} + +/// Remove cascade agents from `agents` and persist via the injectable `save`. +/// +/// Extracted from `delete_persona` so unit tests can inject a failing save and +/// verify retry-safety without a full `AppHandle` mock: if `save` returns `Err`, +/// this function propagates it before the keyring deletions and tombstones that +/// appear after the `?` in the call site — nothing is destroyed and the command +/// is safe to retry. +fn commit_cascade_agents( + agents: &mut Vec, + cascade: &std::collections::HashSet, + save: impl FnOnce(&[ManagedAgentRecord]) -> Result<(), String>, +) -> Result<(), String> { + agents.retain(|a| !cascade.contains(&a.pubkey)); + save(agents) +} #[tauri::command] pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut personas = load_personas(&app)?; - let persona = personas - .iter() - .find(|record| record.id == id) - .ok_or_else(|| format!("persona {id} not found"))?; - let referenced_by_team = load_teams(&app)?.iter().any(|team| { - team.persona_ids + + { + // Store lock held across all three phases. + // Lock ordering: store lock (acquired here) → process lock (per-agent in Phase 2). + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Load and validate the persona before any destructive work. + let mut personas = load_personas(&app)?; + let persona = personas .iter() - .any(|persona_id| persona_id == id.as_str()) - }); - validate_persona_deletion(persona, referenced_by_team)?; - // Capture the coordinate before the record leaves the list. Only reached - // for non-builtin, non-team personas (validate_persona_deletion rejects - // both), so every deleted persona here is one this owner published. - let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona); + .find(|record| record.id == id) + .ok_or_else(|| format!("persona {id} not found"))?; + let referenced_by_team = load_teams(&app)?.iter().any(|team| { + team.persona_ids + .iter() + .any(|persona_id| persona_id == id.as_str()) + }); + validate_persona_deletion(persona, referenced_by_team)?; + // Capture the coordinate before the record might leave the list. Only + // reached for non-builtin, non-team personas (both rejected above), + // so every deleted persona here is one this owner published. + let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona); - let original_len = personas.len(); - personas.retain(|record| record.id != id); - if personas.len() == original_len { - return Err(format!("persona {id} not found")); - } - save_personas(&app, &personas)?; - tombstone_persona_pending(&app, &state, &d_tag); - - let mut agents = load_managed_agents(&app)?; - let mut changed_agents = false; - let now = now_iso(); - for agent in &mut agents { - if agent.persona_id.as_deref() == Some(id.as_str()) { - agent.persona_id = None; - agent.updated_at = now.clone(); - changed_agents = true; + // ── Phase 1: Stage ───────────────────────────────────────────── + // + // Load agents, sync process state, and build the cascade set. Lock + // ordering: store lock (held) → process lock (acquired for sync, + // then released before Phase 2 stops). Every fallible read/lock is + // here; an error leaves all state intact and the command is retryable. + let mut agents = load_managed_agents(&app)?; + { + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + let (sync_changed, exited_pubkeys) = sync_managed_agent_processes( + &mut agents, + &mut runtimes, + ¤t_instance_id(&app), + ); + if sync_changed { + save_managed_agents(&app, &agents)?; + } + for pk in &exited_pubkeys { + state.clear_session_cache(pk); + } + // runtimes drops here (process lock released before Phase 2). } + + // Build the cascade set. HashSet for O(1) membership in Phase 3. + let cascade: std::collections::HashSet = + collect_cascade_pubkeys(&agents, &id).into_iter().collect(); + + // Remote-agent pre-flight: refuse the cascade before any destructive + // work while any target is provider-deployed. Nothing in + // create_managed_agent forbids a persona-linked provider agent, so + // this must be a runtime guard, not an assumed invariant. + let remote_deployed = collect_remote_deployed(&agents, &cascade); + if !remote_deployed.is_empty() { + return Err(format!( + "persona {id} has provider-deployed agent instances ({}); delete those agent instances first", + remote_deployed.join(", ") + )); + } + + // ── Phase 2: Stop ─────────────────────────────────────────────── + // + // Best-effort stop each running cascade instance. Lock ordering: + // store lock (held) → process lock acquired per-agent and released + // between stops so the process lock is not held across the full poll + // cycle (stop_managed_agent_process polls 100ms×10 before SIGKILL). + // + // Per-agent stop errors are swallowed — these records are deleted in + // Phase 3 regardless. Intentional difference from delete_managed_agent + // (single-agent, fatal on stop failure); here the cascade is multi-agent + // and deletion must proceed even if one instance cannot be stopped. + for pk in &cascade { + if let Some(rec) = agents.iter_mut().find(|a| a.pubkey == *pk) { + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + if let Err(e) = stop_managed_agent_process(&app, rec, &mut runtimes) { + eprintln!("buzz-desktop: delete_persona: failed to stop agent {pk}: {e}"); + } + // runtimes drops here (per-agent, process lock not held across stops). + } + } + + // ── Phase 3: Commit ───────────────────────────────────────────── + // + // Disk-authoritative writes first, side effects strictly after. + // commit_cascade_agents is an injectable seam so unit tests can + // verify retry-safety: a failing save propagates before any keyring + // deletion or tombstone occurs. + // + // Failure semantics: + // agent save fails → nothing destroyed; full cascade retries cleanly + // persona save fails → cascade agents gone, persona survives; a retry + // finds an empty cascade and proceeds cleanly + // Keys and tombstones are enqueued only after their records leave disk. + if !cascade.is_empty() { + commit_cascade_agents(&mut agents, &cascade, |recs| { + save_managed_agents(&app, recs) + })?; + } + + let original_len = personas.len(); + personas.retain(|record| record.id != id); + if personas.len() == original_len { + return Err(format!("persona {id} not found")); + } + save_personas(&app, &personas)?; + + // Side effects — strictly after records leave disk. + for pk in &cascade { + state.clear_session_cache(pk); + // Remove nsec from keyring after the record is gone. + delete_agent_key(pk); + super::agents::tombstone_managed_agent_pending(&app, &state, pk); + } + tombstone_persona_pending(&app, &state, &d_tag); + + // _store_guard drops here, before try_regenerate_nest. } - if changed_agents { - save_managed_agents(&app, &agents)?; - } + try_regenerate_nest(&app); Ok(()) @@ -466,10 +535,18 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { /// a pending local edit leaves the local record — and its queued publish — /// untouched. #[tauri::command] -pub fn reconcile_inbound_persona_event( +pub async fn reconcile_inbound_persona_event( + event_json: String, + app: AppHandle, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || reconcile_inbound_persona_event_blocking(event_json, app)) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +fn reconcile_inbound_persona_event_blocking( event_json: String, app: AppHandle, - state: State<'_, AppState>, ) -> Result<(), String> { use crate::managed_agents::{ agent_events::managed_agent_content_from_event, @@ -482,8 +559,8 @@ pub fn reconcile_inbound_persona_event( use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; use nostr::JsonUtil; - let event = nostr::Event::from_json(&event_json) - .map_err(|e| format!("failed to parse inbound event: {e}"))?; + let state = app.state::(); + let event = parse_verified_inbound_event(&event_json)?; // The live filter subscribes to 30175/30176/30177 (upserts) plus kind:5 // (NIP-09 deletions). d-tags are NOT unique across kinds, so every path @@ -573,6 +650,21 @@ pub fn reconcile_inbound_persona_event( Ok(()) } +/// Parse an inbound wire event and enforce the signature gate. Everything +/// downstream trusts `event.pubkey` (ownership routing, tombstone scoping, +/// behavioral-quad application), so a forged pubkey must die here — the +/// TS-side owner filter reads the same attacker-controlled field and is no +/// defense. +fn parse_verified_inbound_event(event_json: &str) -> Result { + use nostr::JsonUtil; + let event = nostr::Event::from_json(event_json) + .map_err(|e| format!("failed to parse inbound event: {e}"))?; + event + .verify() + .map_err(|e| format!("inbound event failed signature verification: {e}"))?; + Ok(event) +} + /// Parse a NIP-09 `a`-tag coordinate `::` into its /// target kind and d-tag. Returns `None` if the tag is absent or malformed, so /// the caller no-ops on a tombstone it can't route. @@ -587,7 +679,14 @@ fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> { // most twice and keep the remainder as the d_tag. let mut parts = coord.splitn(3, ':'); let kind: u32 = parts.next()?.parse().ok()?; - let _owner = parts.next()?; + let owner = parts.next()?; + // NIP-09 scoping: only the record's author may tombstone it. The + // signature gate upstream proves `event.pubkey`; requiring the + // coordinate owner to match closes the other half — a validly + // signed kind:5 naming ANOTHER owner's coordinate must no-op. + if owner != event.pubkey.to_hex() { + return None; + } let d_tag = parts.next()?; Some((kind, d_tag.to_string())) }) @@ -698,7 +797,7 @@ fn event_d_tag(event: &nostr::Event) -> Result { /// `created_at` survive. On no match, the parsed record is inserted as-is; since /// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as /// the id and a re-received event stays idempotent (no duplicate row). -fn apply_inbound_persona(personas: &mut Vec, inbound: PersonaRecord) { +fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefinition) { let d_tag = persona_d_tag(&inbound); match personas .iter_mut() @@ -712,6 +811,9 @@ fn apply_inbound_persona(personas: &mut Vec, inbound: PersonaReco local.model = inbound.model; local.provider = inbound.provider; local.name_pool = inbound.name_pool; + local.respond_to = inbound.respond_to; + local.respond_to_allowlist = inbound.respond_to_allowlist; + local.parallelism = inbound.parallelism; local.updated_at = inbound.updated_at; } None => personas.push(inbound), @@ -740,12 +842,19 @@ fn apply_inbound_managed_agent( ) { if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) { local.name = inbound.name; + // Mirror of the slimmed writer (agent_event_content): a + // definition-linked event omits the definition quad because those + // fields resolve through the kind:30175 definition — absent means + // "not carried", never "clear". Definition-less events still carry + // the quad and apply it unconditionally (including clears). + let definition_linked = inbound.persona_id.is_some(); local.persona_id = inbound.persona_id; - local.system_prompt = inbound.system_prompt; - local.model = inbound.model; - local.provider = inbound.provider; - local.mcp_toolsets = inbound.mcp_toolsets; - local.persona_source_version = inbound.persona_source_version; + if !definition_linked { + local.system_prompt = inbound.system_prompt; + local.model = inbound.model; + local.provider = inbound.provider; + local.persona_source_version = inbound.persona_source_version; + } local.parallelism = inbound.parallelism; local.respond_to = inbound.respond_to; local.respond_to_allowlist = inbound.respond_to_allowlist; @@ -767,12 +876,14 @@ fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamE Some(local) => { local.name = inbound.name; local.description = inbound.description; + local.instructions = inbound.instructions; local.persona_ids = inbound.persona_ids; } None => teams.push(TeamRecord { id: d_tag, name: inbound.name, description: inbound.description, + instructions: inbound.instructions, persona_ids: inbound.persona_ids, is_builtin: false, source_dir: None, @@ -790,7 +901,7 @@ pub async fn set_persona_active( id: String, active: bool, app: AppHandle, -) -> Result { +) -> Result { use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -802,7 +913,7 @@ pub async fn set_persona_active( let persona = personas .iter_mut() .find(|record| record.id == id) - .ok_or_else(|| format!("persona {id} not found"))?; + .ok_or_else(|| format!("agent {id} not found"))?; let referenced_by_managed_agent = !active && load_managed_agents(&app)? @@ -838,134 +949,12 @@ pub async fn set_persona_active( .map_err(|e| format!("spawn_blocking failed: {e}"))? } -const MAX_PNG_BYTES: usize = 10 * 1024 * 1024; -const MAX_JSON_BYTES: usize = 5 * 1024 * 1024; -const MAX_ZIP_BYTES: usize = 100 * 1024 * 1024; - -const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47]; -const ZIP_MAGIC: [u8; 4] = [0x50, 0x4B, 0x03, 0x04]; -const JSON_OPEN_BRACE: u8 = 0x7B; - -#[tauri::command] -pub fn parse_persona_files( - file_bytes: Vec, - file_name: String, -) -> Result { - if file_bytes.len() > MAX_ZIP_BYTES { - return Err("File is too large (max 100 MB).".to_string()); - } - if file_bytes.is_empty() { - return Err("File is empty.".to_string()); - } - - let first_byte = file_bytes[0]; - - if file_bytes.len() >= 4 { - let magic: [u8; 4] = file_bytes[..4] - .try_into() - .map_err(|_| "Failed to read file header".to_string())?; - - if magic == PNG_MAGIC { - if file_bytes.len() > MAX_PNG_BYTES { - return Err("PNG file is too large (max 10 MB).".to_string()); - } - let mut preview = parse_png_persona(&file_bytes)?; - preview.source_file = file_name; - return Ok(ParsePersonaFilesResult { - personas: vec![preview], - skipped: vec![], - }); - } - - if magic == ZIP_MAGIC { - return parse_zip_personas(&file_bytes); - } - } - - if first_byte == JSON_OPEN_BRACE { - if file_bytes.len() > MAX_JSON_BYTES { - return Err("JSON file is too large (max 5 MB).".to_string()); - } - let mut preview = parse_json_persona(&file_bytes)?; - preview.source_file = file_name; - return Ok(ParsePersonaFilesResult { - personas: vec![preview], - skipped: vec![], - }); - } - - // .persona.md: YAML frontmatter starts with "---" - let lower_name = file_name.to_ascii_lowercase(); - if lower_name.ends_with(".persona.md") { - if file_bytes.len() > MAX_JSON_BYTES { - return Err("Markdown file is too large (max 5 MB).".to_string()); - } - let mut preview = parse_md_persona(&file_bytes)?; - preview.source_file = file_name; - return Ok(ParsePersonaFilesResult { - personas: vec![preview], - skipped: vec![], - }); - } - - // If it's a .md file but not .persona.md, give a specific hint. - if lower_name.ends_with(".md") { - return Err( - "Only .persona.md files are supported. Rename to .persona.md".to_string(), - ); - } - - Err( - "Unsupported file format. Expected .persona.md, .persona.png, .persona.json, or .zip" - .to_string(), - ) -} - -#[tauri::command] -pub async fn export_persona_to_json( - id: String, - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - // Load persona data under lock, then drop lock before dialog. - // - // NOTE: `env_vars` are deliberately NOT included in the exported card. - // Persona cards are designed to be shareable artifacts (uploaded, - // forked, distributed), and bundling API keys / credentials in them - // would be a significant footgun. Users who import a card and need - // credentials must supply them post-import via the persona dialog. - let (display_name, system_prompt, avatar_url, runtime, model, provider, name_pool) = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let personas = load_personas(&app)?; - let persona = personas - .iter() - .find(|p| p.id == id) - .ok_or_else(|| format!("persona {id} not found"))?; - ( - persona.display_name.clone(), - persona.system_prompt.clone(), - persona.avatar_url.clone(), - persona.runtime.clone(), - persona.model.clone(), - persona.provider.clone(), - persona.name_pool.clone(), - ) - }; - - let json_bytes = encode_persona_json( - &display_name, - &system_prompt, - avatar_url.as_deref(), - runtime.as_deref(), - model.as_deref(), - provider.as_deref(), - &name_pool, - )?; - - let slug = crate::util::slugify(&display_name, "persona", 50); - let filename = format!("{slug}.persona.json"); - save_json_with_dialog(&app, &filename, &json_bytes).await -} +pub(crate) const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47]; +mod snapshot; +pub use snapshot::encode_agent_snapshot_for_send; +pub use snapshot::export_agent_snapshot; +pub(crate) use snapshot::import::{ + decode_snapshot_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES, + MAX_SNAPSHOT_PNG_BYTES, +}; +pub use snapshot::{confirm_agent_snapshot_import, preview_agent_snapshot_import}; diff --git a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs new file mode 100644 index 0000000000..ba855ccbd6 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs @@ -0,0 +1,158 @@ +//! Tests for `propagate_persona_name_rename` — the helper that propagates a +//! persona definition's display_name change to linked agent instances. + +use super::*; + +fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: format!("pubkey-{name}"), + name: name.to_string(), + persona_id: Some(persona_id.to_string()), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + display_name: display_name.map(str::to_string), + slug: None, + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + relay_mesh: None, + } +} + +#[test] +fn test_rename_propagates_to_matching_instance() { + // An instance whose `name` equals the OLD persona display_name must get + // both `name` and `display_name` updated to the new value. + let mut records = vec![agent("persona-1", "Paul", Some("Paul"))]; + + let renamed = propagate_persona_name_rename(&mut records, "persona-1", "Paul", "Paul Atreides"); + + assert_eq!( + renamed, + vec!["pubkey-Paul".to_string()], + "must report the renamed record's pubkey" + ); + assert_eq!(records[0].name, "Paul Atreides", "name must be updated"); + assert_eq!( + records[0].display_name, + Some("Paul Atreides".to_string()), + "display_name must be updated" + ); + // The relay-profile sync params use `record.name`; after rename it carries + // the new display_name, so the relay profile will be published with the correct name. + assert_eq!(records[0].name, "Paul Atreides"); +} + +#[test] +fn test_rename_skips_pool_named_instance() { + // A pool-named instance (e.g. "Birch") has a name DIFFERENT from the + // persona display_name. It must keep its individualised name. + let mut records = vec![agent("persona-1", "Birch", Some("Birch"))]; + + let renamed = propagate_persona_name_rename(&mut records, "persona-1", "Paul", "Paul Atreides"); + + assert!( + renamed.is_empty(), + "pool-named instance must not be reported as renamed" + ); + assert_eq!(records[0].name, "Birch", "pool name must be preserved"); + assert_eq!( + records[0].display_name, + Some("Birch".to_string()), + "pool display_name must be preserved" + ); +} + +#[test] +fn test_rename_propagates_both_name_and_display_name() { + // Explicit dual-field check: BOTH `name` and `display_name` must be + // updated so the relay profile and the local UI are consistent. + let mut records = vec![agent("persona-1", "OldName", None)]; + + propagate_persona_name_rename(&mut records, "persona-1", "OldName", "NewName"); + + assert_eq!(records[0].name, "NewName"); + assert_eq!(records[0].display_name, Some("NewName".to_string())); +} + +#[test] +fn test_rename_only_affects_linked_persona() { + // An instance linked to a DIFFERENT persona must not be touched, even + // if it happens to carry the same display_name. + let mut records = vec![ + agent("persona-1", "Paul", Some("Paul")), + agent("persona-2", "Paul", Some("Paul")), + ]; + + propagate_persona_name_rename(&mut records, "persona-1", "Paul", "Paul Atreides"); + + assert_eq!(records[0].name, "Paul Atreides", "linked instance renamed"); + assert_eq!( + records[1].name, "Paul", + "unrelated persona's instance untouched" + ); +} + +#[test] +fn test_rename_renames_all_matching_instances_in_one_pass() { + // Several instances may carry the definition name (multi-instance deploys + // without a name pool): one call renames every match and reports each + // pubkey, which is what the relay profile sync collection keys on. + let mut records = vec![ + agent("persona-1", "Paul", Some("Paul")), + agent("persona-1", "Paul", Some("Paul")), + agent("persona-1", "Birch", Some("Birch")), + ]; + records[1].pubkey = "pubkey-Paul-2".to_string(); + + let renamed = propagate_persona_name_rename(&mut records, "persona-1", "Paul", "Duncan Idaho"); + + assert_eq!( + renamed, + vec!["pubkey-Paul".to_string(), "pubkey-Paul-2".to_string()], + "every matching instance's pubkey must be reported" + ); + assert_eq!(records[0].name, "Duncan Idaho"); + assert_eq!(records[1].name, "Duncan Idaho"); + assert_eq!(records[2].name, "Birch", "pool-named instance untouched"); +} diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs new file mode 100644 index 0000000000..4d887ca39e --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -0,0 +1,134 @@ +//! Retention-store enqueue helpers for owner-authored persona writes: retain +//! a pending 30175 on create/update, purge + tombstone on delete. The flush +//! loop (`flush_pending_events`) is the sole publisher. + +use tauri::AppHandle; + +use crate::app_state::AppState; +use crate::managed_agents::AgentDefinition; + +/// Retain a freshly authored persona event in the local store, flagged for +/// relay sync. Called inside a command's `managed_agents_store_lock`-held body +/// after `save_personas`; the background flush loop publishes it out-of-band. +/// +/// The event is signed with the owner keys at call time, so its `created_at` +/// is `now` — newer than any prior retained row, clearing the upsert's +/// newer-or-equal guard. `pending_sync = 1` enqueues it for the flush loop, +/// which is the sole publisher. Best-effort: a failure here is logged and +/// swallowed so a retention hiccup never blocks the disk-authoritative write. +/// +/// Unlike `retain_managed_agent_pending`, this has no projection-equality +/// short-circuit: personas have no start/stop runtime churn, so a republish +/// only happens on a genuine create/update/delete user edit (`set_persona_active` +/// does not retain, so the local-only `is_active` toggle never republishes, and +/// a byte-identical user-save republish is harmlessly NIP-33-replaced). The +/// guard is intentionally omitted. +pub(in crate::commands) fn retain_persona_pending( + app: &AppHandle, + state: &AppState, + persona: &AgentDefinition, +) { + use crate::managed_agents::{ + managed_agents_base_dir, + persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + use nostr::JsonUtil; + + let result = (|| -> Result<(), String> { + let d_tag = persona_d_tag(persona); + let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let (pubkey, event) = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + // Monotonic created_at: read the retained head for this coordinate + // and bump past it (NIP-AP step 3) so a same-second edit supersedes. + let prior = + get_retained_event(&conn, KIND_PERSONA, &keys.public_key().to_hex(), &d_tag)? + .map(|row| row.created_at); + let event = build_persona_event(persona)? + .custom_created_at(monotonic_created_at(prior)) + .sign_with_keys(&keys) + .map_err(|e| format!("failed to sign persona event: {e}"))?; + (keys.public_key().to_hex(), event) + }; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_PERSONA, + pubkey, + d_tag, + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: persona-retain: {e}"); + } +} + +/// Purge a deleted persona's pending row and enqueue a NIP-09 tombstone, both +/// inside the `managed_agents_store_lock`-held delete body. +/// +/// PURGE IN: `delete_retained_event` removes the persona's `(30175, pubkey, +/// d_tag)` row. Running it under the same lock that serializes `retain_event` +/// closes the same-second resurrect race — a concurrent edit can't re-insert a +/// pending persona row after the tombstone is queued. +/// +/// PUBLISH OUT: the kind:5 tombstone is retained at its own coordinate `(5, +/// pubkey, d_tag)` (distinct from the purged persona row) with `pending_sync = +/// 1`; the flush loop publishes it. Best-effort: a failure is logged and +/// swallowed so a retention hiccup never blocks the disk-authoritative delete. +pub(in crate::commands) fn tombstone_persona_pending( + app: &AppHandle, + state: &AppState, + d_tag: &str, +) { + use crate::managed_agents::{ + managed_agents_base_dir, + persona_events::build_persona_delete, + retention::{ + delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, + RetainedEvent, + }, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + use nostr::JsonUtil; + + const KIND_DELETE: u32 = 5; + + let result = (|| -> Result<(), String> { + let (pubkey, event) = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + let pubkey = keys.public_key().to_hex(); + let event = build_persona_delete(d_tag, &pubkey)? + .sign_with_keys(&keys) + .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; + (pubkey, event) + }; + let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + // Purge the persona row first so an unpublished edit can never resurrect + // it after the tombstone publishes. + delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_DELETE, + pubkey, + // Key by the target coordinate so cross-kind d-tag tombstones + // occupy distinct rows (F2c). + d_tag: tombstone_retention_d_tag(KIND_PERSONA, d_tag), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: persona-tombstone: {e}"); + } +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs new file mode 100644 index 0000000000..e4d8a5d1bc --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -0,0 +1,467 @@ +//! `export_agent_snapshot` / `encode_agent_snapshot_for_send` Tauri commands +//! and their supporting helpers. +//! +//! Import-side commands and helpers live in `snapshot::import` to keep this +//! file under the 1000-line gate. +//! +//! Split from `personas/mod.rs` to keep that file under the line-count gate. + +use serde::Serialize; +use tauri::{AppHandle, State}; + +use super::super::export_util::save_bytes_with_dialog; +use crate::{ + app_state::AppState, + commands::engrams::get_agent_memory, + managed_agents::{ + agent_snapshot::{ + build_snapshot, encode_snapshot_json, encode_snapshot_png, AgentSnapshotMemoryEntry, + MemoryLevel, + }, + load_agent_definitions, load_managed_agents, ManagedAgentRecord, + }, +}; + +pub(crate) mod import; + +// Re-export import-side commands so callers see a flat `snapshot::` namespace. +pub use import::{confirm_agent_snapshot_import, preview_agent_snapshot_import}; + +// ── Pure resolver (testable without AppHandle) ──────────────────────────────── + +/// Inner resolver operating on pre-fetched slices — testable without +/// `AppHandle`. +/// +/// Search order: +/// 1. Keyed instances: match `id` against `pubkey` (exact) then `slug`. +/// 2. Keyless definitions: match `id` against `slug`. +/// +/// Returns `(definition_record, is_definition)`. `is_definition` is `true` +/// when the result came from the definitions slice — the caller must not call +/// `get_agent_memory` against it (definitions have no keypair). +pub(crate) fn resolve_from_lists<'a>( + id: &str, + instances: &'a [ManagedAgentRecord], + definitions: &'a [ManagedAgentRecord], +) -> Result<(&'a ManagedAgentRecord, bool), String> { + if let Some(record) = instances + .iter() + .find(|a| a.pubkey == id || a.slug.as_deref() == Some(id)) + { + return Ok((record, false)); + } + if let Some(record) = definitions.iter().find(|a| a.slug.as_deref() == Some(id)) { + return Ok((record, true)); + } + Err(format!("agent {id:?} not found")) +} + +/// Validate that `memory_source_pubkey` is an appropriate source for a +/// memory-bearing snapshot export. +/// +/// For definition exports (`is_definition == true`), the instance must be +/// known and its `persona_id` must equal `def_slug`. +/// For direct instance exports, the pubkey must match the instance itself. +/// +/// Returns the validated pubkey string on success. +pub(crate) fn validate_memory_source( + memory_source_pubkey: &str, + is_definition: bool, + def_id: &str, + instances: &[ManagedAgentRecord], +) -> Result { + let mpk = memory_source_pubkey.trim(); + if mpk.is_empty() { + return Err( + "memory_source_pubkey is required when memory_level is not 'none'. \ + Pass the pubkey of a linked agent instance." + .to_string(), + ); + } + + if is_definition { + // Definition export: the supplied pubkey must be a keyed instance + // whose persona_id equals the definition slug. + let linked = instances + .iter() + .find(|a| a.pubkey == mpk) + .ok_or_else(|| format!("memory_source_pubkey {mpk:?} is not a known agent"))?; + if linked.persona_id.as_deref() != Some(def_id) { + return Err(format!( + "memory_source_pubkey {mpk:?} is not linked to definition {def_id:?}" + )); + } + } else { + // Instance export: the pubkey must match the instance itself. + // This prevents cross-agent memory pairing. + if mpk != def_id { + return Err(format!( + "memory_source_pubkey {mpk:?} does not match agent {def_id:?}" + )); + } + } + + Ok(mpk.to_string()) +} + +/// The encoded bytes and suggested filename for a snapshot, produced by the +/// shared materialization path. Used by both the save-to-disk command and the +/// native-send command so both paths are byte-identical for the same inputs. +pub(crate) struct SnapshotPayload { + pub bytes: Vec, + pub filename: String, +} + +/// Enforce the output size ceiling after encoding. +/// +/// Called by `materialize_snapshot_bytes` before returning bytes to the caller. +/// Both the save-to-disk and send commands therefore reject an oversized result +/// before any file dialog or upload, ensuring that a successfully sent/saved +/// snapshot is always within the importer's acceptance bounds. +/// +/// Extracted as a pure named function so tests can prove the guard logic +/// directly (boundary-1 / boundary / boundary+1 for both formats) without +/// constructing a full `AppHandle`/`AppState`. +pub(crate) fn validate_snapshot_encode_size(bytes_len: usize, is_png: bool) -> Result<(), String> { + if is_png { + if bytes_len > import::MAX_SNAPSHOT_PNG_BYTES { + return Err(format!( + "Snapshot exceeds the {} MiB size limit for .agent.png files. \ + Reduce the avatar image size or use JSON format.", + import::MAX_SNAPSHOT_PNG_BYTES / (1024 * 1024) + )); + } + } else if bytes_len > import::MAX_SNAPSHOT_JSON_BYTES { + return Err(format!( + "Snapshot exceeds the {} MiB size limit for .agent.json files. \ + Reduce memory size or use a config-only snapshot.", + import::MAX_SNAPSHOT_JSON_BYTES / (1024 * 1024) + )); + } + Ok(()) +} + +/// Parse a `memory_level` string to `MemoryLevel`. +fn parse_memory_level(s: &str) -> Result { + match s { + "none" | "" => Ok(MemoryLevel::None), + "core" => Ok(MemoryLevel::Core), + "everything" => Ok(MemoryLevel::Everything), + other => Err(format!( + "Invalid memory_level: {other:?} (expected 'none', 'core', or 'everything')" + )), + } +} + +/// Parse a `format` string to a PNG flag. +fn parse_format_is_png(s: &str) -> Result { + match s { + "json" | "" => Ok(false), + "png" => Ok(true), + other => Err(format!( + "Invalid format: {other:?} (expected 'json' or 'png')" + )), + } +} + +/// Shared production encoding path. +/// +/// Resolves the agent definition, validates inputs, fetches optional memory, +/// builds the snapshot manifest, and encodes to the requested byte format. +/// Does **not** open any file dialog or write to disk — both the save-to-disk +/// and native-send commands call this and then apply their own I/O side effect. +/// +/// **Invariants preserved (identical for both callers):** +/// - JSON/PNG format selection from the parsed `is_png` flag (magic-byte sniffing is import-only) +/// - Memory-source pubkey validation +/// - Secret exclusion (env_vars never enter the manifest via `build_snapshot`) +/// - Output filename derived from the agent display name +pub(crate) async fn materialize_snapshot_bytes( + id: String, + memory_source_pubkey: Option, + memory_level: MemoryLevel, + is_png: bool, + avatar_png_data_url: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // ── Load definition record and memory-source instance under lock ───────── + let (record, memory_pubkey) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let instances = load_managed_agents(&app)?; + let definitions = load_agent_definitions(&app)?; + let (def_record, is_definition) = resolve_from_lists(&id, &instances, &definitions) + .map(|(r, is_def)| (r.clone(), is_def))?; + + let memory_pubkey = if memory_level != MemoryLevel::None { + let mpk = memory_source_pubkey.as_deref().unwrap_or(""); + let def_id = if is_definition { + def_record.slug.as_deref().unwrap_or("") + } else { + &def_record.pubkey + }; + Some(validate_memory_source( + mpk, + is_definition, + def_id, + &instances, + )?) + } else { + None + }; + + (def_record, memory_pubkey) + }; + + let display_name = record + .display_name + .clone() + .unwrap_or_else(|| record.name.clone()); + + // ── Resolve avatar bytes ───────────────────────────────────────────────── + // If the avatar_url is a data URL we decode it inline; otherwise we keep + // it as an external reference in the manifest (the importer will use it). + let avatar_bytes: Option> = record + .avatar_url + .as_deref() + .and_then(crate::managed_agents::agent_snapshot::decode_avatar_data_url); + + // ── Fetch memory ───────────────────────────────────────────────────────── + let memory_entries: Vec = if let Some(pubkey) = memory_pubkey { + let listing = get_agent_memory(pubkey, app.clone(), state).await?; + let mut entries = Vec::new(); + if let Some(core) = listing.core { + entries.push(AgentSnapshotMemoryEntry { + slug: core.slug, + body: core.body, + }); + } + if memory_level == MemoryLevel::Everything { + for mem in listing.memories { + entries.push(AgentSnapshotMemoryEntry { + slug: mem.slug, + body: mem.body, + }); + } + } + entries + } else { + Vec::new() + }; + + // ── Build manifest ─────────────────────────────────────────────────────── + let snapshot = build_snapshot( + &record, + memory_level, + memory_entries, + avatar_bytes.as_deref(), + ); + + // ── Encode ─────────────────────────────────────────────────────────────── + let slug = crate::util::slugify(&display_name, "agent", 50); + + if is_png { + let png_body_avatar_bytes = + resolve_png_body_avatar_bytes(avatar_png_data_url.as_deref(), avatar_bytes); + let png_bytes = encode_snapshot_png(&snapshot, png_body_avatar_bytes.as_deref()) + .map_err(|e| format!("Failed to encode .agent.png: {e}"))?; + validate_snapshot_encode_size(png_bytes.len(), true)?; + Ok(SnapshotPayload { + bytes: png_bytes, + filename: format!("{slug}.agent.png"), + }) + } else { + let json_bytes = encode_snapshot_json(&snapshot) + .map_err(|e| format!("Failed to encode .agent.json: {e}"))?; + validate_snapshot_encode_size(json_bytes.len(), false)?; + Ok(SnapshotPayload { + bytes: json_bytes, + filename: format!("{slug}.agent.json"), + }) + } +} + +/// Choose bytes for the PNG image body without changing the source avatar the +/// manifest preserves for import. +fn resolve_png_body_avatar_bytes( + avatar_png_data_url: Option<&str>, + store_avatar_bytes: Option>, +) -> Option> { + avatar_png_data_url + .and_then(crate::managed_agents::agent_snapshot::decode_avatar_data_url) + .or(store_avatar_bytes) +} + +/// Export an agent definition as a `buzz-agent-snapshot v1` file. +/// +/// `id` is a definition slug or a keyed-instance pubkey. +/// `memory_source_pubkey` is required when `memory_level != "none"` — it must +/// be a keyed-instance pubkey whose `persona_id` matches `id` (validated +/// server-side so the UI cannot supply a mismatched pairing). +/// `memory_level` is one of `"none"`, `"core"`, or `"everything"`. +/// `format` is either `"json"` or `"png"`. +/// +/// The user picks the save path via the OS dialog. Returns `true` when the +/// file was written, `false` when the dialog was cancelled. +#[tauri::command] +pub async fn export_agent_snapshot( + id: String, + memory_source_pubkey: Option, + memory_level: String, + format: String, + avatar_png_data_url: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let memory_level = parse_memory_level(&memory_level)?; + let is_png = parse_format_is_png(&format)?; + + let payload = materialize_snapshot_bytes( + id, + memory_source_pubkey, + memory_level, + is_png, + avatar_png_data_url, + app.clone(), + state, + ) + .await?; + + if is_png { + save_bytes_with_dialog( + &app, + &payload.filename, + "PNG image", + &["png"], + &payload.bytes, + ) + .await + } else { + save_bytes_with_dialog( + &app, + &payload.filename, + "Agent snapshot", + &["json"], + &payload.bytes, + ) + .await + } +} + +/// Wire shape returned by `encode_agent_snapshot_for_send`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EncodedSnapshotPayload { + /// Raw snapshot bytes as a byte array. The frontend passes these directly + /// to `uploadMediaBytes` (which accepts `number[]`). + pub file_bytes: Vec, + /// Suggested filename, e.g. `my-agent.agent.json`. + pub file_name: String, +} + +/// Encode a `buzz-agent-snapshot v1` payload in memory and return the raw +/// bytes to the frontend for the native-send path. +/// +/// Performs identical resolution, validation, and encoding as +/// `export_agent_snapshot` (via the shared `materialize_snapshot_bytes` path) +/// but **never opens a file dialog**. The frontend passes the returned bytes +/// through `uploadMediaBytes` → message construction → channel/DM send. +#[tauri::command] +pub async fn encode_agent_snapshot_for_send( + id: String, + memory_source_pubkey: Option, + memory_level: String, + format: String, + avatar_png_data_url: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let memory_level = parse_memory_level(&memory_level)?; + let is_png = parse_format_is_png(&format)?; + + let payload = materialize_snapshot_bytes( + id, + memory_source_pubkey, + memory_level, + is_png, + avatar_png_data_url, + app, + state, + ) + .await?; + + Ok(EncodedSnapshotPayload { + file_bytes: payload.bytes, + file_name: payload.filename, + }) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod png_body_tests { + use super::*; + use base64::Engine as _; + use png::Decoder; + + #[test] + fn frontend_raster_becomes_png_body_without_replacing_manifest_source() { + let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 3, + 2, + image::Rgb([0x12, 0x34, 0x56]), + )); + let mut source_png = Vec::new(); + avatar + .write_to( + &mut std::io::Cursor::new(&mut source_png), + image::ImageFormat::Png, + ) + .unwrap(); + let avatar_data_url = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&source_png) + ); + let snapshot = crate::managed_agents::agent_snapshot::AgentSnapshot { + format: crate::managed_agents::agent_snapshot::FORMAT_DISCRIMINATOR.to_string(), + version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, + definition: crate::managed_agents::agent_snapshot::AgentSnapshotDefinition { + name: "Agent".to_string(), + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: crate::managed_agents::agent_snapshot::AgentSnapshotProfile { + display_name: "Agent".to_string(), + about: None, + avatar_data_url: None, + avatar_url: Some("https://relay.example/media/avatar.png".to_string()), + }, + memory: crate::managed_agents::agent_snapshot::AgentSnapshotMemory { + level: MemoryLevel::None, + entries: vec![], + }, + }; + let body_bytes = resolve_png_body_avatar_bytes(Some(&avatar_data_url), None); + let png_bytes = encode_snapshot_png(&snapshot, body_bytes.as_deref()).unwrap(); + let reader = Decoder::new(std::io::Cursor::new(&png_bytes)) + .read_info() + .unwrap(); + let decoded = import::decode_snapshot_from_bytes(&png_bytes).unwrap(); + + assert_eq!((reader.info().width, reader.info().height), (3, 2)); + assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url); + } +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs new file mode 100644 index 0000000000..9e2b8b3921 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -0,0 +1,681 @@ +//! Import-side helpers for `buzz-agent-snapshot v1`. +//! +//! Extracted from `snapshot.rs` to keep that file under the 1000-line gate. +//! The Tauri commands here (`preview_agent_snapshot_import`, +//! `confirm_agent_snapshot_import`) are re-exported from `snapshot.rs` and +//! registered in `lib.rs` through the same `personas::` path as the export +//! commands. + +use nostr::ToBech32; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + agent_snapshot::{decode_snapshot_json, decode_snapshot_png, MemoryLevel}, + load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, + ManagedAgentRecord, RespondTo, + }, + relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, + util::now_iso, +}; + +/// Maximum snapshot file size accepted before decode (5 MiB for JSON, +/// 10 MiB for PNG). Mirrors the established persona-import limits. +pub(crate) const MAX_SNAPSHOT_JSON_BYTES: usize = 5 * 1024 * 1024; +pub(crate) const MAX_SNAPSHOT_PNG_BYTES: usize = 10 * 1024 * 1024; + +const LEGACY_PERSONA_FILE_SUFFIXES: [&str; 4] = + [".persona.md", ".persona.json", ".persona.png", ".zip"]; + +pub(super) fn reject_legacy_persona_filename(file_name: &str) -> Result<(), String> { + if LEGACY_PERSONA_FILE_SUFFIXES + .iter() + .any(|suffix| file_name.to_ascii_lowercase().ends_with(suffix)) + { + return Err( + "Legacy persona files are no longer supported. Export an .agent.json or .agent.png snapshot instead." + .to_string(), + ); + } + Ok(()) +} + +// ── Import preview types ────────────────────────────────────────────────────── + +/// Materialized preview returned to the UI before any write is committed. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotImportPreview { + /// Agent display name from the snapshot. + pub display_name: String, + /// System prompt, if any. + pub system_prompt: Option, + /// Effective avatar: data URL if present, otherwise the source URL fallback. + /// The UI renders this as a single avatar source. + pub avatar_url: Option, + /// Memory level declared in the snapshot. + pub memory_level: String, + /// Number of memory entries bundled in the snapshot. + pub memory_entry_count: usize, + /// True when the snapshot's `respond_to_allowlist` is non-empty. These + /// pubkeys come from the source environment and are meaningless on the + /// importer's relay — the UI must offer Keep / Clear. + pub has_source_allowlist: bool, + /// Number of source allowlist entries. + pub source_allowlist_count: usize, +} + +/// The confirmation request sent from the UI after the user reviews the preview. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotImportConfirm { + /// Raw bytes of the snapshot file (.agent.json or .agent.png). + pub file_bytes: Vec, + /// When true, copy source `respond_to_allowlist` to the new agent. + /// When false (the safe default), the allowlist is cleared. + pub keep_allowlist: bool, +} + +/// Structured result returned after a confirmed import. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotImportResult { + /// Display name of the newly created agent. + pub display_name: String, + /// Pubkey of the new agent (hex). + pub new_pubkey: String, + /// Persona id created for the agent. + pub persona_id: String, + /// Total memory entries successfully written to the relay. + pub memory_written: usize, + /// Total memory entries that were in the snapshot. + pub memory_total: usize, + /// Non-empty when one or more memory entries failed to publish. + /// The agent itself was created successfully — only memory is partial. + pub memory_errors: Vec, + /// Non-empty when profile sync encountered a non-fatal relay error. + pub profile_sync_error: Option, +} + +// ── Import helpers ───────────────────────────────────────────────────────── + +/// Resolve the behavioral defaults for an incoming agent snapshot. +/// +/// This is the single authoritative selection path for all import-time +/// allowlist and behavioral decisions. It is extracted as a pure, testable +/// function so that unit tests exercise the exact production logic rather +/// than a reconstruction of it. +/// +/// # UI contract +/// +/// The Keep/Clear toggle is shown whenever `has_source_allowlist` is true +/// (i.e. the raw allowlist is non-empty), regardless of the source mode. +/// The mode (`respond_to` wire string) and the list are independent axes. +/// +/// # Decision table +/// +/// | Source mode | Non-empty list | keep=true | keep=false | +/// |--------------|----------------|----------------------|-------------------------| +/// | allowlist | yes | preserve mode + list | owner-only + empty | +/// | allowlist | no | **Err** (reject) | **Err** (reject) | +/// | non-allowlist| yes | preserve mode + list | preserve mode + empty | +/// | non-allowlist| no | preserve mode | preserve mode | +/// +/// Allowlist-mode + empty list is always rejected: the UI showed no choice +/// and there is no coherent value to write. +/// +/// Non-allowlist + non-empty + Clear: preserve the source mode but empty the +/// list. Only allowlist-mode requires a mode downgrade on Clear, because +/// `allowlist` without entries is an invalid state. Non-allowlist modes +/// remain valid with an empty list. +pub(crate) fn resolve_snapshot_import_behavior( + raw_respond_to: Option<&str>, + raw_allowlist: &[String], + parallelism: Option, + keep_allowlist: bool, +) -> Result { + use crate::managed_agents::{ + resolve_mint_behavioral_defaults, validate_respond_to_allowlist, RespondTo, + }; + + // Step 1: normalize allowlist; reject malformed pubkeys immediately. + let normalized_allowlist = validate_respond_to_allowlist(raw_allowlist)?; + + // Step 2: detect source mode and whether a list was present. + let source_mode: Option = match raw_respond_to { + Some(wire) => Some(RespondTo::parse_wire(wire)?), + None => None, + }; + let is_source_allowlist_mode = source_mode == Some(RespondTo::Allowlist); + let has_source_allowlist = !normalized_allowlist.is_empty(); + + // Step 3: hard-reject allowlist-mode + empty list before any key + // generation — no coherent value can be written either way. + if is_source_allowlist_mode && !has_source_allowlist { + return Err( + "snapshot respond-to mode is 'allowlist' but the allowlist is empty — \ + cannot import: no pubkeys to grant access to" + .to_string(), + ); + } + + // Step 4: apply Keep/Clear when the toggle was visible (list non-empty), + // or preserve the source mode when it was not. + let (resolved_mode, resolved_allowlist) = if has_source_allowlist { + if keep_allowlist { + // Keep: preserve source mode and validated list. + (source_mode, normalized_allowlist) + } else if is_source_allowlist_mode { + // Clear on allowlist-mode: must downgrade mode to owner-only because + // allowlist mode without entries is an invalid state. + (Some(RespondTo::OwnerOnly), Vec::new()) + } else { + // Clear on non-allowlist mode: preserve source mode, empty the list. + // Non-allowlist modes are valid without entries. + (source_mode, Vec::new()) + } + } else { + // No list present → toggle was never shown; preserve source mode as-is. + (source_mode, normalized_allowlist) + }; + + resolve_mint_behavioral_defaults( + resolved_mode, + resolved_allowlist, + parallelism, + None, // no definition record; all inputs are explicit from the snapshot + ) +} + +const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47]; + +/// Decode a `buzz-agent-snapshot v1` manifest from raw bytes. +/// +/// Sniffs by magic bytes (PNG signature) first, then falls back to JSON. +/// Fails closed on malformed content, wrong format, or unsupported version. +/// Never trusts the file extension — only the bytes. +/// +/// **Memory consistency:** any manifest whose `memory.entries` is non-empty +/// despite `memory.level == None` is rejected before any write, regardless of +/// the enclosing format. +/// +/// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected +/// before allocation to avoid avoidable large-input work. +pub(crate) fn decode_snapshot_from_bytes( + file_bytes: &[u8], +) -> Result { + if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC { + if file_bytes.len() > MAX_SNAPSHOT_PNG_BYTES { + return Err(format!( + "Snapshot file is too large ({} MiB). PNG snapshots must be under 10 MiB.", + file_bytes.len() / (1024 * 1024) + )); + } + let snapshot = decode_snapshot_png(file_bytes)?; + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { + return Err( + "Snapshot is malformed: memory.level is 'none' but entries are present." + .to_string(), + ); + } + return Ok(snapshot); + } + // JSON path — apply size cap before serde allocation. + if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES { + return Err(format!( + "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.", + file_bytes.len() / (1024 * 1024) + )); + } + let snapshot = decode_snapshot_json(file_bytes)?; + // Consistency check: none + non-empty entries is always malformed, + // regardless of format. Mirrors the PNG path above so the rule is + // enforced at decode time for both formats. + if !snapshot.memory.entries.is_empty() && snapshot.memory.level == MemoryLevel::None { + return Err( + "Snapshot is malformed: memory.level is 'none' but entries are present.".to_string(), + ); + } + Ok(snapshot) +} + +// ── `preview_agent_snapshot_import` ────────────────────────────────────────── + +/// Decode and validate a snapshot file, returning a preview for the +/// confirmation UI. No writes of any kind are performed. +/// +/// `file_bytes` is the raw binary content of the `.agent.json` or +/// `.agent.png` file. The format is sniffed from the content, not the +/// extension, so an incorrectly-named file is handled correctly. +/// +/// Returns an `AgentSnapshotImportPreview` or a descriptive error. Errors +/// represent irrecoverable failures (corrupt / unsupported file) and are +/// shown directly to the user. +#[tauri::command] +pub async fn preview_agent_snapshot_import( + file_bytes: Vec, + file_name: String, +) -> Result { + tokio::task::spawn_blocking(move || { + reject_legacy_persona_filename(&file_name)?; + let snapshot = decode_snapshot_from_bytes(&file_bytes)?; + + let memory_level = match snapshot.memory.level { + MemoryLevel::None => "none", + MemoryLevel::Core => "core", + MemoryLevel::Everything => "everything", + } + .to_string(); + + Ok(AgentSnapshotImportPreview { + display_name: snapshot.profile.display_name.clone(), + system_prompt: snapshot.definition.system_prompt.clone(), + // Effective avatar: data URL wins; URL fallback if no data URL. + avatar_url: snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()), + memory_level, + memory_entry_count: snapshot.memory.entries.len(), + source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), + has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), + }) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +// ── `confirm_agent_snapshot_import` ────────────────────────────────────────── + +/// Import a `buzz-agent-snapshot v1` file as a brand-new agent. +/// +/// Phase sequence: +/// 1. Validate — decode the manifest and reject early on any error. +/// 2. Mint — generate a new keypair + NIP-OA auth tag; create a +/// `AgentDefinition` + `ManagedAgentRecord` through the same primitives +/// used by the normal create flow. +/// 3. Publish — kind:30175 definition via retention path; kind:0 profile +/// via `sync_managed_agent_profile`. +/// 4. Memory — for each opted-in entry, build a fresh `kind:30174` event +/// with `engram::build_event` under the new agent↔owner conversation +/// key and POST it to the relay. Failures are collected and returned as +/// `memory_errors`; the agent itself is already created. +/// +/// Importing the same file twice yields two distinct agents with different +/// keypairs. No source identity material (pubkey, nsec, auth_tag, relay_url, +/// env_vars, backend, lineage) is consumed. +#[tauri::command] +pub async fn confirm_agent_snapshot_import( + input: AgentSnapshotImportConfirm, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // ── Phase 1: validate (no I/O) ─────────────────────────────────────────── + let snapshot = decode_snapshot_from_bytes(&input.file_bytes)?; + + let display_name = snapshot.profile.display_name.trim().to_string(); + if display_name.is_empty() { + return Err("Snapshot display name is empty.".to_string()); + } + + // ── Resolve behavioral defaults ────────────────────────────────────────── + let minted = resolve_snapshot_import_behavior( + snapshot.definition.respond_to.as_deref(), + &snapshot.definition.respond_to_allowlist, + snapshot.definition.parallelism, + input.keep_allowlist, + )?; + let minted_parallelism = minted.parallelism; + + // Effective avatar: data URL wins; URL fallback when data URL is absent. + let effective_avatar: Option = snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()); + + // Wire-format string for the persona definition's respond_to field. + // Omit when it is the default (owner-only) to keep definitions clean. + let respond_to_wire: Option = if minted.respond_to != RespondTo::default() { + Some(minted.respond_to.as_str().to_string()) + } else { + None + }; + + // ── Phase 2: mint keys + auth tag (sync, outside lock) ─────────────────── + let (agent_keys, private_key_nsec, pubkey, auth_tag, owner_pubkey_hex) = { + let owner_keys = state.signing_keys()?; + let agent_keys = nostr::Keys::generate(); + let pubkey = agent_keys.public_key().to_hex(); + let private_key_nsec = agent_keys + .secret_key() + .to_bech32() + .map_err(|e| format!("failed to encode agent private key: {e}"))?; + + // NIP-OA auth tag: bridge nostr 0.37 → 0.36 (buzz-sdk) via hex round-trip. + let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|e| format!("failed to bridge owner keys: {e}"))?; + let compat_agent = nostr::PublicKey::from_hex(&pubkey) + .map_err(|e| format!("failed to bridge agent pubkey: {e}"))?; + let auth_tag = Some( + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "") + .map_err(|e| format!("failed to compute NIP-OA auth tag: {e}"))?, + ); + let owner_pubkey_hex = owner_keys.public_key().to_hex(); + ( + agent_keys, + private_key_nsec, + pubkey, + auth_tag, + owner_pubkey_hex, + ) + }; + + // ── Phase 3a: create AgentDefinition + ManagedAgentRecord (sync lock) ────── + let (persona, record) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let mut personas = load_personas(&app)?; + let mut records = load_managed_agents(&app)?; + + // Guard against duplicate pubkey (astronomically unlikely but safe). + if records.iter().any(|r| r.pubkey == pubkey) { + return Err(format!("generated pubkey {pubkey} already exists — retry")); + } + + let now = now_iso(); + let persona_id = uuid::Uuid::new_v4().to_string(); + + // Build persona from snapshot definition. + let persona = AgentDefinition { + id: persona_id.clone(), + display_name: display_name.clone(), + avatar_url: effective_avatar.clone(), + system_prompt: snapshot + .definition + .system_prompt + .clone() + .unwrap_or_default(), + runtime: snapshot.definition.runtime.clone(), + model: snapshot.definition.model.clone(), + provider: snapshot.definition.provider.clone(), + name_pool: snapshot.definition.name_pool.clone(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: std::collections::BTreeMap::new(), + respond_to: respond_to_wire.clone(), + respond_to_allowlist: minted.respond_to_allowlist.clone(), + parallelism: minted_parallelism, + created_at: now.clone(), + updated_at: now.clone(), + }; + + personas.push(persona.clone()); + save_personas(&app, &personas)?; + + // Enqueue the kind:30175 persona event via the retention path. + super::super::pending::retain_persona_pending(&app, &state, &persona); + + // Build the managed agent record — no machine-local commands, no + // secrets, no lineage from the snapshot. + let record = ManagedAgentRecord { + pubkey: pubkey.clone(), + name: display_name.clone(), + display_name: None, + slug: None, + persona_id: Some(persona_id.clone()), + private_key_nsec: private_key_nsec.clone(), + auth_tag: auth_tag.clone(), + relay_url: String::new(), // resolves to workspace relay at runtime + avatar_url: effective_avatar.clone(), + // Machine-local commands: derive from the runtime catalog at + // spawn time — never manufacture from snapshot data. + acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: snapshot.definition.idle_timeout_seconds, + max_turn_duration_seconds: snapshot.definition.max_turn_duration_seconds, + parallelism: minted_parallelism + .unwrap_or(crate::managed_agents::DEFAULT_AGENT_PARALLELISM), + system_prompt: snapshot.definition.system_prompt.clone(), + model: snapshot.definition.model.clone(), + provider: snapshot.definition.provider.clone(), + persona_source_version: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: now.clone(), + updated_at: now.clone(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + // Instance-level behavioral defaults agree with the resolved + // definition: both come from the single minted struct so they + // are always consistent at mint time. + respond_to: minted.respond_to, + respond_to_allowlist: minted.respond_to_allowlist.clone(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: respond_to_wire.clone(), + definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), + definition_parallelism: minted_parallelism, + relay_mesh: None, + runtime: snapshot.definition.runtime.clone(), + name_pool: snapshot.definition.name_pool.clone(), + }; + + records.push(record.clone()); + save_managed_agents(&app, &records)?; + + // Enqueue the kind:30177 managed-agent event via retention. + // (Uses the same pattern as agents.rs::retain_managed_agent_pending + // inlined here to avoid cross-module private-fn access.) + retain_agent_pending(&app, &state, &record); + + crate::managed_agents::try_regenerate_nest(&app); + + // Notify other mounted clients of local persona+managed-agent writes, + // matching the contract used by other local managed-agent mutations. + let _ = app.emit("agents-data-changed", ()); + + (persona, record) + }; + + // ── Phase 3b: publish kind:0 profile (async, outside lock) ─────────────── + let relay_url = + effective_agent_relay_url(&record.relay_url, &relay_ws_url_with_override(&state)); + let profile_sync_error = sync_managed_agent_profile( + &state, + &relay_url, + &agent_keys, + &display_name, + effective_avatar.as_deref(), + auth_tag.as_deref(), + ) + .await + .err(); + + // ── Phase 4: restore memory (async, outside lock) ───────────────────────── + let memory_total = snapshot.memory.entries.len(); + let mut memory_written = 0usize; + let mut memory_errors: Vec = Vec::new(); + + if memory_total > 0 { + let owner_pubkey = nostr::PublicKey::from_hex(&owner_pubkey_hex) + .map_err(|e| format!("failed to parse owner pubkey: {e}"))?; + + // Monotonic timestamp seed: use current time, bumped by 1 per entry + // so no two events land at the same second. + let base_ts = nostr::Timestamp::now().as_secs(); + + for (idx, entry) in snapshot.memory.entries.iter().enumerate() { + let body = if entry.slug == buzz_core_pkg::engram::CORE_SLUG { + buzz_core_pkg::engram::Body::Core { + profile: entry.body.clone(), + } + } else { + buzz_core_pkg::engram::Body::Memory { + slug: entry.slug.clone(), + value: Some(entry.body.clone()), + } + }; + + let created_at = base_ts + idx as u64; + match buzz_core_pkg::engram::build_event(&agent_keys, &owner_pubkey, &body, created_at) + { + Ok(event) => { + let event_json = nostr::JsonUtil::as_json(&event).into_bytes(); + let url = format!("{}/events", crate::relay::relay_http_base_url(&relay_url)); + match submit_engram_event( + &state, + &agent_keys, + &event_json, + &url, + auth_tag.as_deref(), + ) + .await + { + Ok(()) => memory_written += 1, + Err(e) => memory_errors.push(format!("slug {:?}: {e}", entry.slug)), + } + } + Err(e) => { + memory_errors.push(format!("slug {:?}: build failed: {e}", entry.slug)); + } + } + } + } + + Ok(AgentSnapshotImportResult { + display_name, + new_pubkey: pubkey, + persona_id: persona.id, + memory_written, + memory_total, + memory_errors, + profile_sync_error, + }) +} + +/// Inline retention for the managed-agent kind:30177 event — mirrors +/// `agents::retain_managed_agent_pending` without requiring cross-module +/// private function access. +fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { + use crate::managed_agents::{ + agent_events::{agent_event_content, build_agent_event}, + managed_agents_base_dir, + persona_events::monotonic_created_at, + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + }; + use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + use nostr::JsonUtil; + + let result = (|| -> Result<(), String> { + let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let content = serde_json::to_string(&agent_event_content(record)) + .map_err(|e| format!("failed to serialize agent content: {e}"))?; + let (owner_pubkey, event) = { + let keys = state.signing_keys()?; + let owner_pubkey = keys.public_key().to_hex(); + let existing = + get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; + if existing.as_ref().is_some_and(|row| row.content == content) { + return Ok(()); + } + let event = build_agent_event(record)? + .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) + .sign_with_keys(&keys) + .map_err(|e| format!("failed to sign agent event: {e}"))?; + (owner_pubkey, event) + }; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner_pubkey, + d_tag: record.pubkey.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: snapshot-import retain-agent: {e}"); + } +} + +/// POST a pre-built signed engram event to the relay, authenticating as the +/// new agent. +async fn submit_engram_event( + state: &AppState, + agent_keys: &nostr::Keys, + event_json: &[u8], + url: &str, + auth_tag: Option<&str>, +) -> Result<(), String> { + use crate::relay::build_nip98_auth_header_for_keys; + use reqwest::Method; + + let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?; + let mut request = state + .http_client + .post(url) + .header("Authorization", auth) + .header("Content-Type", "application/json"); + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } + let response = request + .body(event_json.to_vec()) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + if !response.status().is_success() { + let msg = crate::relay::relay_error_message(response).await; + return Err(format!("relay rejected engram: {msg}")); + } + + let body = response + .text() + .await + .map_err(|e| format!("failed to read relay response: {e}"))?; + let parsed: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("relay response not JSON: {e}"))?; + let accepted = parsed + .get("accepted") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !accepted { + let message = parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + return Err(format!("relay rejected engram: {message}")); + } + Ok(()) +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs new file mode 100644 index 0000000000..b1d19f06b6 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -0,0 +1,971 @@ +use super::import::{ + decode_snapshot_from_bytes, reject_legacy_persona_filename, resolve_snapshot_import_behavior, + AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, +}; +use super::*; +use crate::managed_agents::{ + agent_snapshot::{ + AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotMemoryEntry, + AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }, + BackendKind, ManagedAgentRecord, RespondTo, +}; +use std::collections::BTreeMap; + +// ── Shared fixtures ─────────────────────────────────────────────────────── + +/// Build a minimal keyless definition record (matched by slug, no keypair). +/// This is the shape stored in the definitions file — no pubkey, no +/// persona_id. +fn make_definition(slug: &str) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + slug: Some(slug.to_string()), + name: slug.to_string(), + display_name: None, + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: false, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: false, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + relay_mesh: None, + } +} + +/// Build a minimal keyed instance. Real instances minted by `create_persona` +/// have `slug: None` and link to their definition via `persona_id`. +fn make_instance(pubkey: &str, persona_id: &str) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: pubkey.to_string(), + slug: None, + persona_id: Some(persona_id.to_string()), + ..make_definition("") + } +} + +/// Build a minimal valid AgentSnapshot for import tests. +fn make_snapshot( + memory_level: MemoryLevel, + entries: Vec, +) -> AgentSnapshot { + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "Test Agent".to_string(), + system_prompt: Some("You are helpful.".to_string()), + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test Agent".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: memory_level, + entries, + }, + } +} + +// ── Joint happy path ────────────────────────────────────────────────────── +// +// Production record shape: a keyless definition (slug = "my-agent") and +// a keyed instance (slug = None, pubkey = "instance-pk", persona_id = +// "my-agent") live in separate stores. +// +// This one test exercises the full resolver → validator composition: +// 1. Resolving "my-agent" finds the *definition* (the instance has no +// slug so the instance search misses it). +// 2. The linked instance pubkey validates as the memory source. + +#[test] +fn definition_slug_resolves_to_definition_and_linked_instance_is_valid_memory_source() { + let def = make_definition("my-agent"); + let inst = make_instance("instance-pk", "my-agent"); + + let defs = vec![def]; + let instances = vec![inst]; + + // Step 1 — resolution: slug finds the definition, not the instance. + let (record, is_def) = resolve_from_lists("my-agent", &instances, &defs).unwrap(); + assert!( + is_def, + "slug 'my-agent' must resolve to the definition, not the instance" + ); + assert_eq!(record.slug.as_deref(), Some("my-agent")); + + // Step 2 — memory source validation: instance-pk is persona_id-linked. + let def_slug = record.slug.as_deref().unwrap_or(""); + let result = validate_memory_source("instance-pk", is_def, def_slug, &instances); + assert_eq!( + result.unwrap(), + "instance-pk", + "linked keyed instance must be accepted as the memory source" + ); +} + +// ── Resolver edge cases ─────────────────────────────────────────────────── + +#[test] +fn resolve_by_pubkey_finds_keyed_instance() { + let inst = make_instance("pubkey-xyz", "my-agent"); + let instances = vec![inst]; + let (record, is_def) = resolve_from_lists("pubkey-xyz", &instances, &[]).unwrap(); + assert!(!is_def); + assert_eq!(record.pubkey, "pubkey-xyz"); +} + +#[test] +fn resolve_unknown_id_returns_error() { + let result = resolve_from_lists("ghost", &[], &[]); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("ghost")); +} + +// ── Validator fail-closed cases ─────────────────────────────────────────── + +#[test] +fn memory_export_without_pubkey_fails() { + let result = validate_memory_source("", true, "my-agent", &[]); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("memory_source_pubkey is required"), + "empty pubkey must be rejected with a clear message" + ); +} + +#[test] +fn definition_export_with_instance_linked_to_other_definition_fails() { + // Instance persona_id points to "other-agent", not "my-agent". + let inst = make_instance("instance-pk", "other-agent"); + let instances = vec![inst]; + let result = validate_memory_source("instance-pk", true, "my-agent", &instances); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("is not linked to definition"), + "mismatched persona_id must fail closed" + ); +} + +#[test] +fn direct_instance_export_with_nonmatching_memory_pubkey_fails() { + // Cross-agent memory pairing: memory pubkey differs from instance pubkey. + let result = validate_memory_source("other-agent-pk", false, "agent-pk", &[]); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("does not match agent"), + "cross-agent memory pairing must fail closed" + ); +} + +// ── Import: decode_snapshot_from_bytes ──────────────────────────────────── + +/// JSON bytes sniff as JSON → round-trip through the same preview path. +#[test] +fn import_sniff_json_bytes_decodes_correctly() { + use crate::managed_agents::agent_snapshot::encode_snapshot_json; + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + // Must NOT start with PNG magic — confirm it's plain JSON. + assert_ne!(&bytes[..4], &[0x89, 0x50, 0x4e, 0x47]); + let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); + assert_eq!(decoded, snapshot); +} + +/// PNG bytes sniff as PNG → decoded via the PNG path. +#[test] +fn import_sniff_png_bytes_decodes_correctly() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + assert_eq!(&png_bytes[..4], &[0x89, 0x50, 0x4e, 0x47]); + let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap(); + assert_eq!(decoded, snapshot); +} + +/// Legacy persona file names fail with migration guidance before decoding. +#[test] +fn import_legacy_persona_filename_returns_snapshot_migration_error() { + for file_name in [ + "agent.persona.md", + "agent.persona.json", + "agent.persona.png", + "agent.zip", + ] { + let error = reject_legacy_persona_filename(file_name).unwrap_err(); + assert_eq!( + error, + "Legacy persona files are no longer supported. Export an .agent.json or .agent.png snapshot instead." + ); + } +} + +/// Corrupt/random bytes fail before any writes. +#[test] +fn import_corrupt_bytes_fail_closed() { + let result = decode_snapshot_from_bytes(b"not a valid snapshot at all"); + assert!(result.is_err(), "corrupt bytes must fail closed"); +} + +/// Unsupported format string fails closed. +#[test] +fn import_wrong_format_string_fails_closed() { + use crate::managed_agents::agent_snapshot::encode_snapshot_json; + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.format = "not-buzz-agent-snapshot".to_string(); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let result = decode_snapshot_from_bytes(&bytes); + assert!(result.is_err(), "wrong format must fail closed"); + assert!( + result.unwrap_err().contains("Unsupported snapshot format"), + "error must describe the format problem" + ); +} + +/// Unsupported version fails closed. +#[test] +fn import_unsupported_version_fails_closed() { + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.version = 99; + // validate_snapshot inside decode_snapshot_json rejects version != 1; + // temporarily bypass it by serializing raw and patching the JSON. + let json = serde_json::to_string(&snapshot).unwrap(); + let result = decode_snapshot_from_bytes(json.as_bytes()); + assert!(result.is_err(), "unsupported version must fail closed"); + assert!( + result.unwrap_err().contains("Unsupported snapshot version"), + "error must describe the version problem" + ); +} + +/// Completely empty input fails closed. +#[test] +fn import_empty_bytes_fail_closed() { + let result = decode_snapshot_from_bytes(b""); + assert!(result.is_err(), "empty bytes must fail closed"); +} + +/// JSON snapshot over 5 MiB is rejected before decode allocation. +#[test] +fn import_json_over_size_cap_is_rejected() { + // Construct a byte slice that looks like JSON (no PNG magic) and exceeds + // MAX_SNAPSHOT_JSON_BYTES (5 MiB). Content doesn't need to be valid JSON + // because the size check fires before serde. + let oversized = vec![b'{'; MAX_SNAPSHOT_JSON_BYTES + 1]; + let result = decode_snapshot_from_bytes(&oversized); + assert!(result.is_err(), "oversized JSON must be rejected"); + assert!( + result.unwrap_err().contains("too large"), + "error must mention size" + ); +} + +/// PNG snapshot over 10 MiB is rejected before decode allocation. +#[test] +fn import_png_over_size_cap_is_rejected() { + // Start with the PNG magic, then pad to exceed MAX_SNAPSHOT_PNG_BYTES. + let mut oversized = vec![0u8; MAX_SNAPSHOT_PNG_BYTES + 1]; + oversized[0] = 0x89; + oversized[1] = 0x50; // P + oversized[2] = 0x4e; // N + oversized[3] = 0x47; // G + let result = decode_snapshot_from_bytes(&oversized); + assert!(result.is_err(), "oversized PNG must be rejected"); + assert!( + result.unwrap_err().contains("too large"), + "error must mention size" + ); +} + +/// Effective avatar resolution: data URL takes precedence over source URL. +#[test] +fn import_avatar_data_url_takes_precedence_over_url() { + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.profile.avatar_data_url = Some("data:image/png;base64,abc".to_string()); + snapshot.profile.avatar_url = Some("https://example.com/avatar.png".to_string()); + + // Simulate the effective_avatar resolution logic. + let effective = snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()); + assert_eq!( + effective.as_deref(), + Some("data:image/png;base64,abc"), + "data URL must win over source URL" + ); +} + +/// Effective avatar resolution: URL fallback is used when data URL is absent. +#[test] +fn import_avatar_url_fallback_is_used_when_no_data_url() { + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.profile.avatar_data_url = None; + snapshot.profile.avatar_url = Some("https://example.com/avatar.png".to_string()); + + let effective = snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()); + assert_eq!( + effective.as_deref(), + Some("https://example.com/avatar.png"), + "URL fallback must be used when data URL is absent" + ); +} + +// ── Import: PNG memory parity ───────────────────────────────────────────── + +/// PNG with core memory round-trips through the production import decoder. +#[test] +fn import_png_with_core_memory_preserves_entries() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot( + MemoryLevel::Core, + vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "Remember this.".to_string(), + }], + ); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap(); + + assert_eq!(decoded.memory, snapshot.memory); +} + +/// PNG with all memory round-trips through the production import decoder. +#[test] +fn import_png_with_everything_memory_preserves_entries() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot( + MemoryLevel::Everything, + vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "Remember this.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/research".to_string(), + body: "Notes.".to_string(), + }, + ], + ); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap(); + + assert_eq!(decoded.memory, snapshot.memory); +} + +/// PNG with `none` level but non-empty entries is rejected as malformed. +#[test] +fn import_png_with_none_level_and_entries_is_rejected() { + use crate::managed_agents::agent_snapshot::{ + encode_snapshot_json, encode_snapshot_png, make_png_with_text, PNG_CHUNK_KEYWORD, + }; + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + let snapshot = make_snapshot( + MemoryLevel::None, + vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "Leak.".to_string(), + }], + ); + let json = encode_snapshot_json(&snapshot).unwrap(); + let png_bytes = make_png_with_text(PNG_CHUNK_KEYWORD, &STANDARD.encode(json)).unwrap(); + + assert!(encode_snapshot_png(&snapshot, None).is_err()); + let error = decode_snapshot_from_bytes(&png_bytes).unwrap_err(); + assert!(error.contains("'none' but entries are present")); +} + +/// PNG with `none` level and no entries imports normally. +#[test] +fn import_png_with_none_level_and_no_entries_succeeds() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let result = decode_snapshot_from_bytes(&png_bytes); + assert!( + result.is_ok(), + "PNG with none level and no entries must succeed" + ); +} + +/// JSON with explicitly opted-in memory decodes successfully (memory is +/// allowed in JSON format — it is warned about in the preview UI). +#[test] +fn import_json_with_memory_decodes_and_warns() { + use crate::managed_agents::agent_snapshot::encode_snapshot_json; + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "I remember things.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "Some notes.".to_string(), + }, + ]; + let snapshot = make_snapshot(MemoryLevel::Everything, entries); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); + // Memory survives — the preview UI will warn the user. + assert_eq!(decoded.memory.level, MemoryLevel::Everything); + assert_eq!(decoded.memory.entries.len(), 2); +} + +// ── Import: none + non-empty entries ───────────────────────────────────── + +/// memory.level == none with non-empty entries is rejected in +/// decode_snapshot_from_bytes (the rule is enforced at decode time so it +/// covers both preview, confirm, and the remote fetch boundary uniformly). +#[test] +fn import_none_level_with_entries_is_rejected() { + // Produce JSON bytes with level:none but non-empty entries. serde happily + // serializes this — the guard fires in decode_snapshot_from_bytes. + let raw = serde_json::json!({ + "format": "buzz-agent-snapshot", + "version": 1, + "definition": { "name": "test" }, + "profile": { "displayName": "Test" }, + "memory": { + "level": "none", + "entries": [{"slug": "core", "body": "Some body"}] + } + }); + let bytes = serde_json::to_vec(&raw).unwrap(); + let result = decode_snapshot_from_bytes(&bytes); + assert!( + result.is_err(), + "none level with non-empty entries must be rejected by decode_snapshot_from_bytes" + ); + assert!( + result + .unwrap_err() + .contains("'none' but entries are present"), + "error must describe the inconsistency" + ); +} + +/// A well-formed snapshot with memory level != none and non-empty entries +/// is accepted. +#[test] +fn import_memory_bearing_snapshot_is_accepted() { + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "I am a test agent.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/research".to_string(), + body: "Some research.".to_string(), + }, + ]; + let snapshot = make_snapshot(MemoryLevel::Everything, entries); + let is_inconsistent = + snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty(); + assert!( + !is_inconsistent, + "well-formed memory-bearing snapshot must not be flagged inconsistent" + ); + assert_eq!(snapshot.memory.entries.len(), 2); +} + +// ── Import: allowlist surfacing + enforcement ───────────────────────────── + +/// A snapshot with non-empty respond_to_allowlist sets has_source_allowlist. +#[test] +fn import_preview_flags_non_empty_source_allowlist() { + use crate::managed_agents::agent_snapshot::encode_snapshot_json; + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.definition.respond_to_allowlist = vec!["aabbcc".repeat(11)[..64].to_string()]; // 64 hex chars + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); + assert!( + !decoded.definition.respond_to_allowlist.is_empty(), + "source allowlist must survive encode/decode" + ); + // Simulate preview logic: + let has_source_allowlist = !decoded.definition.respond_to_allowlist.is_empty(); + assert!( + has_source_allowlist, + "preview must flag non-empty source allowlist" + ); +} + +// ── Import: resolve_snapshot_import_behavior — the production selection path +// +// All tests below call `resolve_snapshot_import_behavior` directly. This is +// the same function invoked by `confirm_agent_snapshot_import`, so the tests +// exercise the exact production code path, not a reconstruction of it. + +/// Uppercase hex pubkeys are lowercased and duplicates are removed before the +/// resolved allowlist is persisted. +#[test] +fn import_allowlist_uppercase_is_normalized_and_deduplicated() { + let upper = "AABBCC".repeat(11)[..64].to_string(); + let lower = upper.to_ascii_lowercase(); + // allowlist-mode source + keep=true: normalization applies. + let minted = resolve_snapshot_import_behavior( + Some("allowlist"), + &[upper.clone(), upper.clone()], // uppercase + duplicate + None, + true, // keep + ) + .unwrap(); + assert_eq!( + minted.respond_to_allowlist, + vec![lower], + "uppercase must be lowercased, duplicate removed" + ); +} + +/// A malformed pubkey in the raw allowlist is rejected before any key +/// generation or write. +#[test] +fn import_allowlist_malformed_pubkey_is_rejected() { + let bad = "notahexpubkey".to_string(); + let err = resolve_snapshot_import_behavior(Some("allowlist"), &[bad], None, true).unwrap_err(); + assert!( + err.contains("invalid pubkey"), + "malformed pubkey must be rejected before key generation: {err}" + ); +} + +/// keep_allowlist = false on a non-allowlist source (anyone) with an empty +/// list preserves the source mode — no list was present so the toggle was +/// never shown. +#[test] +fn import_non_allowlist_mode_preserved_when_keep_false() { + use crate::managed_agents::RespondTo; + let minted = resolve_snapshot_import_behavior( + Some("anyone"), + &[], // no allowlist — toggle never shown + None, + false, + ) + .unwrap(); + assert_eq!( + minted.respond_to, + RespondTo::Anyone, + "anyone mode must be preserved when list is empty (toggle not shown)" + ); + assert!( + minted.respond_to_allowlist.is_empty(), + "anyone mode must have no allowlist" + ); +} + +/// Non-allowlist mode with a non-empty list and keep=true: preserve mode + list. +/// The toggle WAS shown (list is non-empty) so keep_allowlist applies. +#[test] +fn import_non_allowlist_mode_with_nonempty_list_keep_preserves_mode_and_list() { + use crate::managed_agents::RespondTo; + let raw = "aabbcc".repeat(11)[..64].to_string(); + let minted = resolve_snapshot_import_behavior( + Some("anyone"), + std::slice::from_ref(&raw), + None, + true, // keep + ) + .unwrap(); + assert_eq!( + minted.respond_to, + RespondTo::Anyone, + "anyone mode must be preserved on keep=true" + ); + assert_eq!( + minted.respond_to_allowlist, + vec![raw], + "list must be preserved on keep=true" + ); +} + +/// Non-allowlist mode with a non-empty list and keep=false: Clear preserves +/// the source mode but empties the list. Non-allowlist modes are valid +/// without entries — no mode downgrade occurs. +#[test] +fn import_non_allowlist_mode_with_nonempty_list_clear_preserves_mode_empties_list() { + use crate::managed_agents::RespondTo; + let raw = "aabbcc".repeat(11)[..64].to_string(); + let minted = resolve_snapshot_import_behavior( + Some("anyone"), + &[raw], + None, + false, // clear + ) + .unwrap(); + assert_eq!( + minted.respond_to, + RespondTo::Anyone, + "non-allowlist mode must be preserved on clear (mode is valid without entries)" + ); + assert!( + minted.respond_to_allowlist.is_empty(), + "cleared list must be empty" + ); +} + +/// keep_allowlist = true with a valid non-empty allowlist-mode snapshot +/// preserves the mode and the full allowlist. +#[test] +fn import_allowlist_keep_with_valid_list_succeeds() { + use crate::managed_agents::RespondTo; + let raw = "aabbcc".repeat(11)[..64].to_string(); + let minted = resolve_snapshot_import_behavior( + Some("allowlist"), + std::slice::from_ref(&raw), + None, + true, // keep + ) + .unwrap(); + assert_eq!(minted.respond_to, RespondTo::Allowlist); + assert_eq!(minted.respond_to_allowlist, vec![raw]); +} + +/// keep_allowlist = false on an allowlist-mode source with a non-empty list +/// downgrades to owner-only and clears the allowlist. +#[test] +fn import_allowlist_clear_downgrades_to_owner_only() { + use crate::managed_agents::RespondTo; + let raw = "aabbcc".repeat(11)[..64].to_string(); + let minted = resolve_snapshot_import_behavior( + Some("allowlist"), + &[raw], + None, + false, // clear + ) + .unwrap(); + assert_eq!( + minted.respond_to, + RespondTo::OwnerOnly, + "clear on allowlist-mode must yield owner-only" + ); + assert!( + minted.respond_to_allowlist.is_empty(), + "cleared allowlist must be empty" + ); +} + +/// Allowlist-mode snapshot with an empty list and keep=false (the default +/// when no UI choice was shown) is rejected before any key generation. +/// This is the primary defect path that the prior implementation missed. +#[test] +fn import_empty_allowlist_mode_rejected_with_keep_false() { + let err = resolve_snapshot_import_behavior( + Some("allowlist"), + &[], // empty — no entries to keep or clear + None, + false, // keep=false is the default: UI never showed a choice + ) + .unwrap_err(); + assert!( + err.contains("allowlist is empty"), + "empty allowlist-mode + keep=false must be rejected before key generation: {err}" + ); +} + +/// Allowlist-mode snapshot with an empty list and keep=true is also rejected. +/// The user explicitly said Keep, but there is nothing to keep. +#[test] +fn import_empty_allowlist_mode_rejected_with_keep_true() { + let err = resolve_snapshot_import_behavior( + Some("allowlist"), + &[], // empty + None, + true, // keep=true: user said Keep, but the list is empty + ) + .unwrap_err(); + assert!( + err.contains("allowlist is empty"), + "empty allowlist-mode + keep=true must be rejected before key generation: {err}" + ); +} + +/// Out-of-range parallelism (0 and 33) is rejected. +#[test] +fn import_out_of_range_parallelism_is_rejected() { + for bad_par in [0u32, 33u32] { + let err = resolve_snapshot_import_behavior( + None, // no respond_to (defaults to owner-only) + &[], + Some(bad_par), + false, + ) + .unwrap_err(); + assert!( + err.contains("out of range"), + "parallelism {bad_par} must be rejected: {err}" + ); + } +} + +// ── Import: identity-never-travels ─────────────────────────────────────── + +/// Importing the same snapshot bytes twice must yield two distinct pubkeys. +/// This verifies Keys::generate() is called fresh each import (not mocked +/// in unit tests, but the property is that two calls never collide). +#[test] +fn import_twice_produces_distinct_pubkeys() { + let key1 = nostr::Keys::generate(); + let key2 = nostr::Keys::generate(); + assert_ne!( + key1.public_key().to_hex(), + key2.public_key().to_hex(), + "two fresh keypairs must never share a pubkey" + ); +} + +/// No source identity or machine-local field from the snapshot ends up in +/// the new agent record. Verified by checking that: +/// - the new pubkey is not a string present in the snapshot JSON +/// - private_key_nsec is not in the snapshot JSON +/// - relay_url, env_vars, auth_tag are not carried from the snapshot +#[test] +fn import_source_identity_fields_never_consumed() { + use crate::managed_agents::agent_snapshot::encode_snapshot_json; + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + // Inject a fake source pubkey into the definition name to simulate a + // snapshot that carries identity-looking data. + snapshot.definition.name = "agent-from-source".to_string(); + snapshot.profile.display_name = "agent-from-source".to_string(); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let json_str = std::str::from_utf8(&bytes).unwrap(); + + // The snapshot must NOT contain any nsec, auth_tag, or relay_url data. + assert!( + !json_str.contains("nsec"), + "snapshot must not contain private key material" + ); + assert!( + !json_str.contains("auth_tag"), + "snapshot must not contain NIP-OA auth tag" + ); + assert!( + !json_str.contains("relay_url"), + "snapshot must not contain relay URL" + ); + assert!( + !json_str.contains("env_vars"), + "snapshot must not contain env vars" + ); + assert!( + !json_str.contains("acp_command"), + "snapshot must not contain machine-local harness command" + ); +} + +// ── Import: memory slug semantics ───────────────────────────────────────── + +/// The "core" slug maps to Body::Core; all other slugs map to Body::Memory. +/// Verified by the sentinel slug value. +#[test] +fn import_core_slug_maps_to_core_body() { + let slug = buzz_core_pkg::engram::CORE_SLUG; + assert_eq!(slug, "core", "CORE_SLUG must equal 'core'"); + // core slug → Body::Core; anything else → Body::Memory + let is_core = slug == buzz_core_pkg::engram::CORE_SLUG; + assert!(is_core, "slug 'core' must map to Body::Core"); + let mem_slug = "mem/research"; + let is_mem = mem_slug != buzz_core_pkg::engram::CORE_SLUG; + assert!(is_mem, "slug 'mem/*' must map to Body::Memory"); +} + +// ── Import: partial-failure boundary ───────────────────────────────────── + +/// AgentSnapshotImportResult correctly represents a partial memory failure: +/// memory_written < memory_total with non-empty memory_errors, and the +/// agent itself is created (new_pubkey is set). +#[test] +fn import_partial_memory_failure_result_is_structured() { + let result = AgentSnapshotImportResult { + display_name: "Test Agent".to_string(), + new_pubkey: "abc123".to_string(), + persona_id: "persona-uuid".to_string(), + memory_written: 1, + memory_total: 3, + memory_errors: vec![ + "slug \"mem/foo\": relay rejected engram: timeout".to_string(), + "slug \"mem/bar\": relay rejected engram: timeout".to_string(), + ], + profile_sync_error: None, + }; + // The agent was created: + assert!(!result.new_pubkey.is_empty(), "new_pubkey must be set"); + // Memory is partial — not full success: + assert_ne!( + result.memory_written, result.memory_total, + "partial write must not equal total" + ); + assert!( + !result.memory_errors.is_empty(), + "partial result must carry error descriptions" + ); + // Not double-counted: errors.len() == total - written + assert_eq!( + result.memory_errors.len(), + result.memory_total - result.memory_written, + "error count must match unwritten entries" + ); +} + +/// Full success: memory_written == memory_total, memory_errors empty. +#[test] +fn import_full_memory_success_result_is_structured() { + let result = AgentSnapshotImportResult { + display_name: "Test Agent".to_string(), + new_pubkey: "abc123".to_string(), + persona_id: "persona-uuid".to_string(), + memory_written: 2, + memory_total: 2, + memory_errors: vec![], + profile_sync_error: None, + }; + assert_eq!( + result.memory_written, result.memory_total, + "full success: written must equal total" + ); + assert!(result.memory_errors.is_empty(), "full success: no errors"); +} + +// ── parse_memory_level / parse_format_is_png ───────────────────────────── + +#[test] +fn test_parse_memory_level_valid_values() { + assert_eq!(parse_memory_level("none").unwrap(), MemoryLevel::None); + assert_eq!(parse_memory_level("").unwrap(), MemoryLevel::None); + assert_eq!(parse_memory_level("core").unwrap(), MemoryLevel::Core); + assert_eq!( + parse_memory_level("everything").unwrap(), + MemoryLevel::Everything + ); +} + +#[test] +fn test_parse_memory_level_invalid_returns_error() { + let err = parse_memory_level("all").unwrap_err(); + assert!(err.contains("Invalid memory_level"), "got: {err}"); + let err2 = parse_memory_level("Core").unwrap_err(); // case-sensitive + assert!(err2.contains("Invalid memory_level"), "got: {err2}"); +} + +#[test] +fn test_parse_format_is_png_valid_values() { + assert!(!parse_format_is_png("json").unwrap()); + assert!(!parse_format_is_png("").unwrap()); + assert!(parse_format_is_png("png").unwrap()); +} + +#[test] +fn test_parse_format_is_png_invalid_returns_error() { + let err = parse_format_is_png("gif").unwrap_err(); + assert!(err.contains("Invalid format"), "got: {err}"); +} + +// ── Export: validate_snapshot_encode_size ──────────────────────────────────── +// +// Tests call `validate_snapshot_encode_size` directly so they prove the exact +// production guard — not a manual reconstruction. Removing or reversing the +// check in production code will cause these tests to fail. + +/// JSON: boundary-1 passes, boundary is the last legal byte count. +#[test] +fn validate_encode_size_json_at_boundary_minus_1_passes() { + assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok()); +} + +/// JSON: exactly at the boundary is the last accepted size. +#[test] +fn validate_encode_size_json_at_boundary_passes() { + assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok()); +} + +/// JSON: boundary+1 is rejected. +#[test] +fn validate_encode_size_json_over_boundary_is_rejected() { + let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err(); + assert!( + err.contains("size limit"), + "error must mention size limit, got: {err}" + ); +} + +/// PNG: boundary-1 passes. +#[test] +fn validate_encode_size_png_at_boundary_minus_1_passes() { + assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok()); +} + +/// PNG: exactly at the boundary passes. +#[test] +fn validate_encode_size_png_at_boundary_passes() { + assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok()); +} + +/// PNG: boundary+1 is rejected. +#[test] +fn validate_encode_size_png_over_boundary_is_rejected() { + let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err(); + assert!( + err.contains("size limit"), + "error must mention size limit, got: {err}" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/writeback.rs b/desktop/src-tauri/src/commands/personas/writeback.rs deleted file mode 100644 index c7a3b170a6..0000000000 --- a/desktop/src-tauri/src/commands/personas/writeback.rs +++ /dev/null @@ -1,910 +0,0 @@ -//! Pack-backed persona write-back: resolve the source `.persona.md` via the -//! pack manifest and rewrite frontmatter fields. Extracted from the parent -//! module to keep it under the desktop file-size cap. Pure relocation. - -use super::*; - -/// Find the team whose `team_persona_key` equals `source_team`. This matches -/// the same key that `sync_team_from_dir` uses, covering both modern teams -/// (where `team.id` equals the manifest directory name) and legacy/backfilled -/// teams (where `team.id` is a UUID and the manifest id is `source_dir.file_name()`). -fn find_team_for_persona_source<'a>( - teams: &'a [TeamRecord], - source_team: &str, -) -> Option<&'a TeamRecord> { - teams - .iter() - .find(|t| t.id == source_team || team_persona_key(t) == source_team) -} - -/// Write updated frontmatter fields back to the source `.persona.md` file for -/// pack-backed personas (`source_team` is set). Non-fatal: any miss (no -/// `source_dir`, missing file, pack load failure, parse or write error) is -/// logged and swallowed so that the in-app edit — already persisted to -/// `personas.json` — always lands. -/// -/// Only the four fields that the UI can set and that live in frontmatter are -/// rewritten: `display_name`, `runtime`, `avatar`, and `model` (the combined -/// `"provider:model"` string used by the pack format). The markdown body is -/// preserved byte-for-byte because `PersonaRecord.system_prompt` is the -/// _composed_ prompt (body + pack instructions appended by `compose_prompt`) -/// and writing it back to the file would cause the instructions to be -/// double-appended on the next launch sync. -/// -/// The source file path is derived from the pack manifest via -/// `buzz_persona_pkg::pack::load_pack` — the same resolution the launch sync -/// uses — rather than reconstructed by convention. This ensures write-back -/// targets the correct file regardless of where the manifest places the -/// `.persona.md` (e.g. `personas/` vs `agents/`, nested paths, or filenames -/// that differ from the persona `name:` field). -/// -/// The team is located via `find_team_for_persona_source`, which matches the -/// same key as `sync_team_from_dir` (`team_persona_key`). This handles both -/// modern teams (where `team.id` equals the manifest id) and legacy/backfilled -/// teams (where `team.id` is a UUID and the manifest id lives in `source_dir`). -pub(super) fn write_back_persona_md(app: &AppHandle, persona: &PersonaRecord) { - // Only pack-backed personas have a source file to write back to. - let Some(source_team_id) = &persona.source_team else { - return; - }; - let Some(slug) = &persona.source_team_persona_slug else { - eprintln!( - "buzz-desktop: persona-writeback: persona {} has source_team but no slug; skipping", - persona.id - ); - return; - }; - - let result = (|| -> Result<(), String> { - let teams = load_teams(app)?; - let team = find_team_for_persona_source(&teams, source_team_id) - .ok_or_else(|| format!("team {source_team_id} not found"))?; - let source_dir = team - .source_dir - .as_ref() - .ok_or_else(|| "team has no source_dir (JSON-only team)".to_string())?; - - // Resolve the actual source file via the pack manifest, matching the - // same path the launch sync reads. `LoadedPersona.source_path` is the - // absolute path set by `safe_resolve` against the pack root, so it is - // correct regardless of the manifest layout. - let pack = buzz_persona_pkg::pack::load_pack(source_dir) - .map_err(|e| format!("load_pack {}: {e}", source_dir.display()))?; - let loaded = pack - .personas - .iter() - .find(|p| p.name == *slug) - .ok_or_else(|| { - format!( - "persona '{slug}' not found in pack at {}", - source_dir.display() - ) - })?; - let path = &loaded.source_path; - - let content = - std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; - - let updated = rewrite_persona_md( - &content, - persona, - &loaded.prompt, - pack.pack_instructions.as_deref(), - )?; - if updated == content { - return Ok(()); - } - std::fs::write(path, &updated).map_err(|e| format!("write {}: {e}", path.display()))?; - Ok(()) - })(); - - if let Err(e) = result { - eprintln!("buzz-desktop: persona-writeback: {e}"); - } -} - -/// Rewrite a `.persona.md` file with updated frontmatter fields and, when safe, -/// an updated body (system prompt). Returns the full rewritten file content, or -/// the original unchanged when the result would be byte-identical. -/// -/// **Frontmatter fields rewritten:** `display_name`, `runtime`, `avatar`, and -/// `model` (joined `"provider:model"` per the pack format). All other keys and -/// their order are preserved. -/// -/// **Body (system prompt) write-back:** -/// The `persona.system_prompt` field holds the *composed* prompt: -/// `compose_prompt(raw_body, pack_instructions)`. To recover the raw body we -/// reverse `compose_prompt`: -/// -/// - If `pack_instructions` is absent or blank: new body = `system_prompt` -/// verbatim (no suffix to strip). -/// - If `pack_instructions` is present and non-blank: the composed prompt ends -/// with `"\n\n---\n# Team Instructions\n{instructions}"`. If -/// `system_prompt` ends with that exact suffix, strip it to get the new raw -/// body. **Safety guard**: if the suffix is absent (user edited inside the -/// Team Instructions block, or instructions drifted), we cannot safely -/// recover the raw body — preserve the existing body and log a skip. This -/// prevents a corrupted file or double-appended instructions. -/// - If `system_prompt` equals `compose_prompt(current_raw_body, instructions)` -/// exactly (user did not edit the prompt), the body is preserved -/// byte-for-byte (no-op for the body section). -fn rewrite_persona_md( - content: &str, - persona: &PersonaRecord, - current_raw_body: &str, - pack_instructions: Option<&str>, -) -> Result { - let (frontmatter, existing_body) = buzz_persona_pkg::persona::split_frontmatter(content) - .map_err(|e| format!("split_frontmatter: {e:?}"))?; - - let mut value = serde_yaml::from_str::(frontmatter) - .map_err(|e| format!("yaml parse: {e}"))?; - let mapping = value - .as_mapping_mut() - .ok_or("frontmatter is not a YAML mapping")?; - - // display_name - mapping.insert( - serde_yaml::Value::String("display_name".to_string()), - serde_yaml::Value::String(persona.display_name.clone()), - ); - - // runtime: set when Some, remove when None - let runtime_key = serde_yaml::Value::String("runtime".to_string()); - match &persona.runtime { - Some(rt) if !rt.is_empty() => { - mapping.insert(runtime_key, serde_yaml::Value::String(rt.clone())); - } - _ => { - mapping.remove(&runtime_key); - } - } - - // avatar: set when Some, remove when None - let avatar_key = serde_yaml::Value::String("avatar".to_string()); - match &persona.avatar_url { - Some(av) if !av.is_empty() => { - mapping.insert(avatar_key, serde_yaml::Value::String(av.clone())); - } - _ => { - mapping.remove(&avatar_key); - } - } - - // model: joined "provider:model" or bare "model"; remove when both absent - let model_key = serde_yaml::Value::String("model".to_string()); - match (&persona.provider, &persona.model) { - (Some(prov), Some(mdl)) if !prov.is_empty() && !mdl.is_empty() => { - mapping.insert( - model_key, - serde_yaml::Value::String(format!("{prov}:{mdl}")), - ); - } - (_, Some(mdl)) if !mdl.is_empty() => { - mapping.insert(model_key, serde_yaml::Value::String(mdl.clone())); - } - _ => { - mapping.remove(&model_key); - } - } - - let updated_frontmatter = - serde_yaml::to_string(&value).map_err(|e| format!("yaml serialize: {e}"))?; - - // Determine the body to write back. - // `compose_prompt` is: body + "\n\n---\n# Team Instructions\n{instructions}" - // when instructions is non-blank, or body verbatim when absent/blank. - let effective_instructions = pack_instructions.filter(|s| !s.trim().is_empty()); - let expected_composed = match effective_instructions { - Some(instr) => format!("{current_raw_body}\n\n---\n# Team Instructions\n{instr}"), - None => current_raw_body.to_owned(), - }; - - let new_body: &str = if persona.system_prompt == expected_composed { - // User did not edit the prompt — keep the existing body byte-for-byte. - existing_body - } else { - // User edited the prompt. Recover the raw body by reversing compose_prompt. - match effective_instructions { - None => { - // No pack instructions: composed == raw, write verbatim. - &persona.system_prompt - } - Some(instr) => { - let suffix = format!("\n\n---\n# Team Instructions\n{instr}"); - if let Some(raw) = persona.system_prompt.strip_suffix(suffix.as_str()) { - raw - } else { - // Safety guard: suffix absent — cannot safely recover raw body. - // Preserve the existing body to avoid corruption or double-append. - eprintln!( - "buzz-desktop: persona-writeback: \ - system_prompt does not end with expected Team Instructions suffix; \ - preserving existing body to avoid corruption" - ); - existing_body - } - } - } - }; - - Ok(format!("---\n{updated_frontmatter}---\n{new_body}")) -} - -#[cfg(test)] -mod writeback_tests { - use super::*; - use std::collections::BTreeMap; - - /// Build a minimal PersonaRecord with the fields that `rewrite_persona_md` reads. - fn persona( - display_name: &str, - runtime: Option<&str>, - avatar_url: Option<&str>, - provider: Option<&str>, - model: Option<&str>, - ) -> PersonaRecord { - // system_prompt matches the SAMPLE_MD body so the "no prompt edit" path - // is taken in rewrite_persona_md (body preserved byte-for-byte). - persona_with_prompt( - display_name, - runtime, - avatar_url, - provider, - model, - "You are Paul.\n", - ) - } - - /// Like `persona` but with an explicit system_prompt value. - fn persona_with_prompt( - display_name: &str, - runtime: Option<&str>, - avatar_url: Option<&str>, - provider: Option<&str>, - model: Option<&str>, - system_prompt: &str, - ) -> PersonaRecord { - PersonaRecord { - id: "test-id".to_string(), - display_name: display_name.to_string(), - avatar_url: avatar_url.map(str::to_string), - system_prompt: system_prompt.to_string(), - runtime: runtime.map(str::to_string), - model: model.map(str::to_string), - provider: provider.map(str::to_string), - name_pool: vec![], - is_builtin: false, - is_active: true, - source_team: Some("team-1".to_string()), - source_team_persona_slug: Some("paul".to_string()), - env_vars: BTreeMap::new(), - created_at: "2025-01-01T00:00:00Z".to_string(), - updated_at: "2025-01-01T00:00:00Z".to_string(), - } - } - - const SAMPLE_MD: &str = "\ ---- -name: paul -display_name: \"Paul\" -description: \"An orchestrator.\" -model: goose-claude-4-6-opus -runtime: goose -extra_key: keep-me ---- -You are Paul. -"; - - // ── rewrite_persona_md unit tests ───────────────────────────────────────── - - #[test] - fn test_rewrite_model_provider_joined_and_body_preserved() { - let p = persona( - "Paul", - Some("goose"), - None, - Some("databricks_v2"), - Some("goose-claude-opus-4-8"), - ); - let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap(); - - // Body is byte-preserved. - assert!( - result.ends_with("\nYou are Paul.\n"), - "body not preserved: {result:?}" - ); - - // model key is the joined form. - assert!( - result.contains("model: databricks_v2:goose-claude-opus-4-8"), - "joined model missing: {result}" - ); - - // No separate provider key. - assert!( - !result.contains("provider:"), - "separate provider key must not be emitted: {result}" - ); - - // Unrelated key preserved. - assert!( - result.contains("extra_key: keep-me"), - "extra key lost: {result}" - ); - - // Still valid frontmatter (parses cleanly). - assert!(result.starts_with("---\n"), "must start with ---"); - } - - #[test] - fn test_rewrite_bare_model_when_provider_none() { - let p = persona("Paul", Some("goose"), None, None, Some("bare-model-id")); - let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap(); - - assert!( - result.contains("model: bare-model-id"), - "bare model missing: {result}" - ); - assert!( - !result.contains("provider:"), - "provider key must not be emitted: {result}" - ); - } - - #[test] - fn test_rewrite_runtime_removed_when_none() { - let p = persona("Paul", None, None, None, Some("some-model")); - let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap(); - - // runtime was in source but persona.runtime is None — key must be removed. - assert!( - !result.contains("runtime:"), - "runtime key should be removed when None: {result}" - ); - } - - #[test] - fn test_rewrite_preserves_description_and_name_and_extra_keys() { - let p = persona( - "Paul Updated", - Some("goose"), - None, - Some("anthropic"), - Some("claude-opus-4"), - ); - let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap(); - - // name and description are not persona record fields — must survive untouched. - assert!(result.contains("name: paul"), "name key lost: {result}"); - assert!( - result.contains("description:"), - "description key lost: {result}" - ); - assert!( - result.contains("extra_key: keep-me"), - "extra_key lost: {result}" - ); - - // display_name updated. - assert!( - result.contains("display_name: Paul Updated") - || result.contains("display_name: \"Paul Updated\""), - "display_name not updated: {result}" - ); - } - - #[test] - fn test_rewrite_no_provider_no_model_removes_model_key() { - let p = persona("Paul", None, None, None, None); - let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap(); - - // When both provider and model are cleared, the model key is removed. - assert!( - !result.contains("model:"), - "model key should be removed when both absent: {result}" - ); - } - - #[test] - fn test_rewrite_avatar_set_and_cleared() { - let with_avatar = persona( - "Paul", - Some("goose"), - Some("data:image/png;base64,abc"), - Some("openai"), - Some("gpt-4o"), - ); - let result = rewrite_persona_md(SAMPLE_MD, &with_avatar, "You are Paul.\n", None).unwrap(); - assert!( - result.contains("avatar:"), - "avatar key should be set: {result}" - ); - - let without_avatar = persona("Paul", Some("goose"), None, Some("openai"), Some("gpt-4o")); - let result = - rewrite_persona_md(SAMPLE_MD, &without_avatar, "You are Paul.\n", None).unwrap(); - assert!( - !result.contains("avatar:"), - "avatar key should be absent when None: {result}" - ); - } - - #[test] - fn test_rewrite_body_not_replaced_by_system_prompt() { - // system_prompt on the PersonaRecord is the COMPOSED prompt (body + pack instructions). - // The body of the .persona.md must not be replaced with it. - let p = persona( - "Paul", - Some("goose"), - None, - Some("databricks_v2"), - Some("goose-claude-opus-4-8"), - ); - let result = rewrite_persona_md(SAMPLE_MD, &p, "You are Paul.\n", None).unwrap(); - - // The raw body from the file ("You are Paul.") is preserved. - assert!( - result.ends_with("You are Paul.\n"), - "raw body must be preserved, not replaced by composed system_prompt: {result:?}" - ); - // The composed instructions suffix must NOT appear in the file body. - assert!( - !result.contains("# Team Instructions"), - "composed system_prompt must not be written to body: {result}" - ); - } - - // ── body (system_prompt) write-back tests ───────────────────────────────── - // - // These tests exercise the compose_prompt inversion logic in rewrite_persona_md. - - /// Frontmatter-only MD (no body to preserve) for prompt tests. - const PROMPT_MD: &str = "\ ---- -name: paul -display_name: \"Paul\" -model: goose-claude-4-6-opus ---- -You are Paul. -"; - - #[test] - fn test_prompt_edited_with_pack_instructions_body_rewritten() { - // User edits the prompt. system_prompt = new_raw_body + separator + instructions. - // Body in file should be updated to new_raw_body; Team Instructions must NOT appear. - let instructions = "Follow the rules."; - let new_raw_body = "You are Paul, a wise orchestrator."; - let composed = format!("{new_raw_body}\n\n---\n# Team Instructions\n{instructions}"); - - let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus")); - p.system_prompt = composed; - - let result = - rewrite_persona_md(PROMPT_MD, &p, "You are Paul.", Some(instructions)).unwrap(); - assert!( - result.ends_with("You are Paul, a wise orchestrator."), - "new body not written: {result:?}" - ); - assert!( - !result.contains("# Team Instructions"), - "Team Instructions must not appear in body: {result}" - ); - } - - #[test] - fn test_prompt_edited_no_pack_instructions_body_rewritten_verbatim() { - // No pack instructions: composed == raw. New body is system_prompt verbatim. - let new_raw_body = "You are Paul, updated."; - let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus")); - p.system_prompt = new_raw_body.to_string(); - - let result = rewrite_persona_md(PROMPT_MD, &p, "You are Paul.", None).unwrap(); - assert!( - result.ends_with("You are Paul, updated."), - "body not updated: {result:?}" - ); - } - - #[test] - fn test_prompt_unedited_body_preserved() { - // system_prompt equals compose_prompt(current_raw_body, instructions) exactly. - // The body section must not change even though frontmatter may be rewritten. - let instructions = "Follow the rules."; - let raw_body = "You are Paul."; - let composed = format!("{raw_body}\n\n---\n# Team Instructions\n{instructions}"); - - let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus")); - // Frontmatter also unchanged (matches PROMPT_MD). - p.system_prompt = composed; - - let result = rewrite_persona_md(PROMPT_MD, &p, raw_body, Some(instructions)).unwrap(); - // Body must remain "You are Paul." (no prompt edit — body section preserved). - assert!( - result.ends_with("You are Paul.\n"), - "body must be unchanged: {result:?}" - ); - // Team Instructions must not leak into the body. - assert!( - !result.contains("# Team Instructions"), - "Team Instructions must not appear: {result}" - ); - } - - #[test] - fn test_prompt_safety_guard_missing_suffix_preserves_body() { - // pack_instructions is non-empty but system_prompt does NOT end with the - // expected suffix (user edited inside the Team Instructions block, or the - // instructions drifted). The existing body must be preserved — no corruption. - let instructions = "Follow the rules."; - let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus")); - // system_prompt lacks the expected suffix entirely. - p.system_prompt = "Some rogue prompt with # Team Instructions in the middle".to_string(); - - let result = - rewrite_persona_md(PROMPT_MD, &p, "You are Paul.", Some(instructions)).unwrap(); - // Body preserved from the file. - assert!( - result.ends_with("You are Paul.\n"), - "body must be preserved by safety guard: {result:?}" - ); - } - - #[test] - fn test_prompt_round_trip_no_double_append() { - // After write-back, running compose_prompt on the written body + instructions - // must reproduce the stored system_prompt exactly. This proves no double-append. - let instructions = "Follow the rules."; - let new_raw_body = "You are Paul, updated for the round-trip."; - let composed = format!("{new_raw_body}\n\n---\n# Team Instructions\n{instructions}"); - - let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus")); - p.system_prompt = composed.clone(); - - let result = - rewrite_persona_md(PROMPT_MD, &p, "You are Paul.", Some(instructions)).unwrap(); - - // Extract the written body (everything after the last "---\n"). - let written_body = result.split("---\n").last().unwrap(); - // Re-compose: body + instructions must equal the original composed prompt. - let recomposed = format!("{written_body}\n\n---\n# Team Instructions\n{instructions}"); - assert_eq!( - recomposed, composed, - "round-trip failed — double-append would occur: recomposed={recomposed:?}" - ); - } - - #[test] - fn test_prompt_edited_with_trailing_newline_in_instructions() { - // Regression: normal instructions.md files have a trailing newline. - // compose_prompt includes it verbatim; system_prompt must NOT be trimmed - // in update_persona or the suffix-strip fails and the safety guard fires. - // - // This test verifies that pack_instructions with a trailing "\n" still - // decomposes correctly — i.e., the body is rewritten, not preserved. - let instructions = "Follow the rules.\n"; // trailing newline from file read - let new_raw_body = "You are Paul, rewritten."; - let composed = format!("{new_raw_body}\n\n---\n# Team Instructions\n{instructions}"); - // system_prompt is NOT trimmed (update_persona must preserve it as-is). - - let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus")); - p.system_prompt = composed; - - let result = - rewrite_persona_md(PROMPT_MD, &p, "You are Paul.", Some(instructions)).unwrap(); - assert!( - result.contains("You are Paul, rewritten."), - "body not rewritten with trailing-newline instructions: {result:?}" - ); - assert!( - !result.contains("# Team Instructions"), - "Team Instructions must not appear in body: {result}" - ); - } - - // ── write_back_persona_md path-resolution tests ─────────────────────────── - // - // These tests verify that write_back_persona_md resolves the source file via - // the pack manifest rather than by convention. This is the class of bug that - // Thufir's IMPORTANT finding caught: a pack whose manifest points at - // `personas/foo.persona.md` (not `agents/.persona.md`) must still be - // rewritten correctly. - - use tempfile::TempDir; - - /// Build a minimal pack on disk with the given personas layout. - /// - /// `persona_entries` is a list of (manifest_rel_path, file_content) pairs. - /// The manifest lists the relative paths; the files are written verbatim. - fn make_temp_pack(persona_entries: &[(&str, &str)]) -> TempDir { - let dir = TempDir::new().expect("tempdir"); - let root = dir.path(); - - std::fs::create_dir_all(root.join(".plugin")).unwrap(); - let persona_paths: Vec<&str> = persona_entries.iter().map(|(p, _)| *p).collect(); - let manifest = serde_json::json!({ - "id": "test-team", - "name": "Test Team", - "version": "0.1.0", - "personas": persona_paths, - }); - std::fs::write( - root.join(".plugin/plugin.json"), - serde_json::to_string_pretty(&manifest).unwrap(), - ) - .unwrap(); - - for (rel_path, content) in persona_entries { - let abs = root.join(rel_path); - std::fs::create_dir_all(abs.parent().unwrap()).unwrap(); - std::fs::write(&abs, content).unwrap(); - } - - dir - } - - #[test] - fn test_writeback_uses_manifest_path_not_convention() { - // Pack uses `personas/paul.persona.md` layout (not `agents/`). - // Convention-based path would derive `agents/paul.persona.md` (wrong). - // Manifest-based resolution must write to the correct `personas/` path. - let persona_md = "\ ---- -name: paul -display_name: \"Paul\" -description: \"Orchestrator\" -model: goose-claude-4-6-opus ---- -You are Paul. -"; - let dir = make_temp_pack(&[("personas/paul.persona.md", persona_md)]); - let source_dir = dir.path().to_path_buf(); - - let pack = buzz_persona_pkg::pack::load_pack(&source_dir).unwrap(); - assert_eq!(pack.personas[0].name, "paul"); - let source_path = pack.personas[0].source_path.clone(); - // File lives under personas/, not agents/. Assert on path components so - // the check is separator-agnostic (Windows uses `\`, not `/`). - assert_eq!( - source_path.file_name().and_then(|n| n.to_str()), - Some("paul.persona.md"), - "expected paul.persona.md: {}", - source_path.display() - ); - assert_eq!( - source_path - .parent() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()), - Some("personas"), - "expected personas/ layout: {}", - source_path.display() - ); - - // Simulate what write_back_persona_md does after the fix: - // use source_path from pack, not source_dir/agents/.persona.md - let mut p = persona( - "Paul Updated", - Some("goose"), - None, - Some("databricks_v2"), - Some("goose-claude-opus-4-8"), - ); - p.source_team_persona_slug = Some("paul".to_string()); - - let content = std::fs::read_to_string(&source_path).unwrap(); - let updated = rewrite_persona_md(&content, &p, "You are Paul.\n", None).unwrap(); - std::fs::write(&source_path, &updated).unwrap(); - - let after = std::fs::read_to_string(&source_path).unwrap(); - assert!( - after.contains("databricks_v2:goose-claude-opus-4-8"), - "model not written: {after}" - ); - assert!( - after.ends_with("You are Paul.\n"), - "body not preserved: {after:?}" - ); - } - - #[test] - fn test_writeback_name_differs_from_filename() { - // Pack file is `personas/orchestrator.persona.md` but `name: paul`. - // Slug matches `name:`, not the filename. - let persona_md = "\ ---- -name: paul -display_name: \"Paul\" -description: \"Orchestrator\" -model: old-model ---- -You are Paul. -"; - let dir = make_temp_pack(&[("personas/orchestrator.persona.md", persona_md)]); - let source_dir = dir.path().to_path_buf(); - - let pack = buzz_persona_pkg::pack::load_pack(&source_dir).unwrap(); - let loaded = pack.personas.iter().find(|p| p.name == "paul").unwrap(); - let source_path = loaded.source_path.clone(); - // File basename is orchestrator, not paul - assert!( - source_path - .to_string_lossy() - .contains("orchestrator.persona.md"), - "expected orchestrator.persona.md: {}", - source_path.display() - ); - - let mut p = persona( - "Paul", - Some("goose"), - None, - Some("anthropic"), - Some("claude-4"), - ); - p.source_team_persona_slug = Some("paul".to_string()); - - let content = std::fs::read_to_string(&source_path).unwrap(); - let updated = rewrite_persona_md(&content, &p, "You are Paul.\n", None).unwrap(); - std::fs::write(&source_path, &updated).unwrap(); - - let after = std::fs::read_to_string(&source_path).unwrap(); - assert!( - after.contains("anthropic:claude-4"), - "model not written to orchestrator.persona.md: {after}" - ); - // The wrong file (paul.persona.md in agents/) must NOT exist. - assert!( - !dir.path().join("agents/paul.persona.md").exists(), - "convention-based path must not be created" - ); - } - - // ── find_team_for_persona_source tests ──────────────────────────────────── - // - // Verify that write_back_persona_md finds teams by team_persona_key, not - // team.id. Legacy/backfilled teams have a UUID `id` while PersonaRecord - // stores the manifest directory name in `source_team`; matching by `id` - // alone silently misses those teams. - - fn make_team(id: &str, source_dir: Option<&str>) -> TeamRecord { - TeamRecord { - id: id.to_string(), - name: id.to_string(), - description: None, - persona_ids: vec![], - is_builtin: false, - source_dir: source_dir.map(|s| std::path::PathBuf::from(s)), - is_symlink: false, - symlink_target: None, - version: None, - created_at: "2026-01-01T00:00:00Z".to_string(), - updated_at: "2026-01-01T00:00:00Z".to_string(), - } - } - - #[test] - fn test_find_team_legacy_uuid_id_matched_by_source_dir_name() { - // Legacy shape: team.id is a UUID; PersonaRecord.source_team is the - // manifest directory name. The old `team.id == source_team` predicate - // missed this — find_team_for_persona_source must match via source_dir. - let teams = vec![make_team("some-uuid-123", Some("/teams/com.test.pack"))]; - let found = find_team_for_persona_source(&teams, "com.test.pack"); - assert!( - found.is_some(), - "legacy team must be found by manifest dir name, not UUID" - ); - assert_eq!(found.unwrap().id, "some-uuid-123"); - } - - #[test] - fn test_find_team_modern_id_matched_directly() { - // Modern shape: team.id equals the manifest directory name. Must match. - let teams = vec![make_team("com.test.pack", Some("/teams/com.test.pack"))]; - let found = find_team_for_persona_source(&teams, "com.test.pack"); - assert!(found.is_some(), "modern team must be found"); - } - - #[test] - fn test_find_team_manifest_dir_name_matches_regardless_of_uuid_id() { - // Regression: the old predicate `team.id == source_team` missed legacy - // teams when source_team holds the manifest dir name, not the UUID. - // This test confirms that searching by manifest dir name always works - // even when team.id is a UUID. - let teams = vec![make_team("some-uuid-123", Some("/teams/com.test.pack"))]; - // source_team holds the manifest dir name "com.test.pack", not the UUID. - let by_dir = find_team_for_persona_source(&teams, "com.test.pack"); - assert!( - by_dir.is_some(), - "manifest dir name must find the legacy team" - ); - assert_eq!(by_dir.unwrap().id, "some-uuid-123"); - } - - #[test] - fn test_find_team_no_source_dir_falls_back_to_id() { - // JSON-only team: no source_dir, team_persona_key falls back to id. - let teams = vec![make_team("builtin-team:fizz", None)]; - let found = find_team_for_persona_source(&teams, "builtin-team:fizz"); - assert!( - found.is_some(), - "no source_dir: must match via team.id fallback" - ); - } - - #[test] - fn test_find_team_returns_none_when_no_match() { - let teams = vec![make_team("some-uuid-123", Some("/teams/com.test.pack"))]; - let not_found = find_team_for_persona_source(&teams, "com.other.pack"); - assert!(not_found.is_none(), "unrelated source_team must not match"); - } - - #[test] - fn test_multi_save_round_trip_short_circuit_holds_both_times() { - // Regression: the `==` short-circuit must hold on the second save too. - // - // Without the frontend fix (removing systemPrompt.trim()), the first - // save stores the trimmed composed prompt; on re-open the form emits the - // trimmed value, which != compose_prompt(body, instructions\n) — the `==` - // check fails, suffix-strip fails on the trimmed string, and the safety - // guard fires on every subsequent save. This test verifies the path stays - // clean across two consecutive no-edit saves. - let instructions = "Follow the rules.\n"; // realistic: trailing newline from file - // `split_frontmatter` strips the "\n" after the closing "---" delimiter - // but preserves the rest of the body verbatim. PROMPT_MD's body line ends - // with "\n", so loaded.prompt at runtime = "You are Paul.\n". - let (_, raw_body) = buzz_persona_pkg::persona::split_frontmatter(PROMPT_MD).unwrap(); - // compose_prompt equivalent (must match the real impl exactly) - let composed = format!("{raw_body}\n\n---\n# Team Instructions\n{instructions}"); - - // ── Save 1: user opens dialog, saves without editing ───────────────── - let mut p = persona("Paul", None, None, None, Some("goose-claude-4-6-opus")); - p.system_prompt = composed.clone(); // no frontend trim — system_prompt is the full composed value - - let written_1 = rewrite_persona_md(PROMPT_MD, &p, raw_body, Some(instructions)).unwrap(); - // Short-circuit must have fired: body is preserved (still "You are Paul.\n"). - assert!( - written_1.ends_with("You are Paul.\n"), - "save 1: body must be preserved by == short-circuit: {written_1:?}" - ); - assert!( - !written_1.contains("# Team Instructions"), - "save 1: Team Instructions must not appear in file body: {written_1}" - ); - - // ── Save 2: user re-opens (reads written_1), saves again without editing - // `loaded.prompt` comes from split_frontmatter on the written file — same - // raw_body as before (the write-back preserved it byte-for-byte). - let (_, body_from_file) = buzz_persona_pkg::persona::split_frontmatter(&written_1).unwrap(); - assert_eq!( - body_from_file, raw_body, - "split_frontmatter after save 1 must return the original raw_body" - ); - - // Compose again from the read-back body — must equal the stored system_prompt. - let recomposed = format!("{body_from_file}\n\n---\n# Team Instructions\n{instructions}"); - assert_eq!( - recomposed, composed, - "save 2 precondition: recomposed must equal stored system_prompt so == holds" - ); - - // Now simulate save 2: system_prompt is the recomposed value (no trim). - p.system_prompt = recomposed; - let written_2 = - rewrite_persona_md(&written_1, &p, body_from_file, Some(instructions)).unwrap(); - // Short-circuit must fire again: body still preserved, no corruption. - assert!( - written_2.ends_with("You are Paul.\n"), - "save 2: body must still be preserved by == short-circuit: {written_2:?}" - ); - assert!( - !written_2.contains("# Team Instructions"), - "save 2: Team Instructions must not appear after second save: {written_2}" - ); - // Both writes are byte-identical — no spurious mutations. - assert_eq!( - written_1, written_2, - "save 2: file content must be identical to save 1" - ); - } -} diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index bfe3b156fc..ab36b45c5c 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -168,6 +168,16 @@ pub async fn get_user_notes( Ok(nostr_convert::user_notes_from_events(&events)) } +fn build_user_search_filter(query: &str, limit: usize, page: u32) -> serde_json::Value { + serde_json::json!({ + "kinds": [0], + "search": query, + "search_mode": "prefix", + "limit": limit, + "page": page, + }) +} + #[tauri::command] pub async fn search_users( query: String, @@ -225,16 +235,14 @@ pub async fn search_users( // `content` blob, where a hit in `display_name` is not weighted any higher // than a substring hit in `about`. The caller can request later pages via the // cursor so the UI cap is only a page size, not a terminal directory ceiling. - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [0], - "search": trimmed, - "limit": max, - "page": page, - })], - ) - .await?; + // + // `search_mode: "prefix"` matters: every caller of this command is a + // typeahead surface (member picker, @mention popup, DM recipient search, + // topbar people results), so a partially typed name must match. Without it + // the relay runs whole-word `websearch_to_tsquery` matching and "tyl" + // returns zero results for "Tyler". Same bridge-only extension the topbar + // message search uses (see `build_search_messages_filter`). + let events = query_relay(&state, &[build_user_search_filter(trimmed, max, page)]).await?; let mut response = nostr_convert::rank_user_search_results(&events, trimmed, max); if events.len() >= max { @@ -319,5 +327,25 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { about: None, nip05_handle: None, owner_pubkey: None, + has_profile_event: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_search_filter_requests_prefix_mode_for_typeahead() { + // Every caller of `search_users` is a typeahead surface. Whole-word + // FTS matching returns zero results for a partially typed name + // ("tyl" for "Tyler"), which reads as "user doesn't exist" in the + // member picker and @mention popup. Pin the mode so it can't drift. + let filter = build_user_search_filter("tyl", 25, 1); + + assert_eq!(filter["search"], serde_json::json!("tyl")); + assert_eq!(filter["search_mode"], serde_json::json!("prefix")); + assert_eq!(filter["limit"], serde_json::json!(25)); + assert_eq!(filter["page"], serde_json::json!(1)); } } diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index 46bc92f80f..c4224f80d4 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -69,6 +69,11 @@ pub struct ProjectRepoPushResult { pub pushed: bool, pub message: String, } +#[derive(Serialize)] +pub struct GitIdentityInfo { + pub name: Option, + pub email: Option, +} fn parse_latest_commit(output: &str) -> Option { let line = output.lines().next()?; let mut parts = line.split('\0'); @@ -580,6 +585,28 @@ fn compare_local_remote_status( } } +/// The viewer's configured git identity (`user.name` / `user.email`), used by +/// the frontend to attribute their own commits to their Buzz profile when git +/// author strings don't match any relay profile fields. +#[tauri::command] +pub async fn get_git_identity(state: State<'_, AppState>) -> Result { + let auth = build_git_auth_config(&state)?; + tauri::async_runtime::spawn_blocking(move || { + let read = |key: &str| { + run_git(&["config", "--get", key], None, &auth) + .ok() + .map(|output| output.trim().to_string()) + .filter(|value| !value.is_empty()) + }; + Ok(GitIdentityInfo { + name: read("user.name"), + email: read("user.email"), + }) + }) + .await + .map_err(|error| format!("git identity task failed: {error}"))? +} + #[tauri::command] pub async fn get_project_repo_snapshot( clone_url: String, diff --git a/desktop/src-tauri/src/commands/project_git_diff.rs b/desktop/src-tauri/src/commands/project_git_diff.rs index f7b3650a06..9111272211 100644 --- a/desktop/src-tauri/src/commands/project_git_diff.rs +++ b/desktop/src-tauri/src/commands/project_git_diff.rs @@ -188,6 +188,40 @@ fn diff_range( .unwrap_or_else(|_| "HEAD^..HEAD".to_string()) } +/// Range for a single commit against its parent, used by the commit detail +/// view. Root commits fall back to the empty tree so the whole initial tree +/// renders as additions. Errors when the commit is not reachable in the +/// available history — diffing an unrelated ref instead would be misleading. +fn commit_parent_range( + repo_dir: &std::path::Path, + auth: &GitAuthConfig, + commit: &str, +) -> Result { + run_git( + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("{commit}^{{commit}}"), + ], + Some(repo_dir), + auth, + ) + .map_err(|_| format!("commit {commit} was not found in the repository history"))?; + let parent = format!("{commit}^"); + if run_git( + &["rev-parse", "--verify", "--quiet", &parent], + Some(repo_dir), + auth, + ) + .is_ok() + { + return Ok(format!("{parent}..{commit}")); + } + let empty_tree = empty_tree_ref(repo_dir, auth)?; + Ok(format!("{empty_tree}..{commit}")) +} + fn local_ref_exists(repo_dir: &std::path::Path, auth: &GitAuthConfig, ref_name: &str) -> bool { run_git( &["rev-parse", "--verify", "--quiet", ref_name], @@ -274,6 +308,17 @@ fn local_diff_range( format!("{base_ref}..{target_ref}") }; } + // With no base at all, a bare commit means "diff against its parent" + // (commit detail view) rather than against the whole tree. + if base_commit.is_none() && base_branch.is_none() { + if let Some(target_commit) = target_commit { + if local_ref_exists(repo_dir, auth, target_commit) { + if let Ok(range) = commit_parent_range(repo_dir, auth, target_commit) { + return range; + } + } + } + } empty_tree_ref(repo_dir, auth) .map(|empty_tree| format!("{empty_tree}..{target_ref}")) .unwrap_or_else(|_| format!("{target_ref}^..{target_ref}")) @@ -374,11 +419,17 @@ pub async fn get_project_repo_diff( target_ref.as_deref(), target_commit.as_deref(), )?; - let range = diff_range( - &repo_dir, - &auth, - diff_base_ref(&repo_dir, &auth, base_branch.as_deref()), - ); + // A commit with no base branch or target ref means "diff this commit + // against its parent" (commit detail view), not "diff HEAD against a + // base". + let range = match (&target_ref, &base_branch, &target_commit) { + (None, None, Some(commit)) => commit_parent_range(&repo_dir, &auth, commit)?, + _ => diff_range( + &repo_dir, + &auth, + diff_base_ref(&repo_dir, &auth, base_branch.as_deref()), + ), + }; diff_from_repo(&repo_dir, &auth, &range) }) .await diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs new file mode 100644 index 0000000000..dc376ca064 --- /dev/null +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -0,0 +1,945 @@ +//! Tauri commands for exporting and importing `buzz-team-snapshot v1` files. +//! +//! Team snapshots are full-instance bundles: importing mints a fresh keypair +//! and `ManagedAgentRecord` for every member plus one `TeamRecord`. Exporting +//! optionally includes member memory at the requested level. + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use uuid::Uuid; + +use crate::{ + app_state::AppState, + commands::{export_util::save_bytes_with_dialog, personas::resolve_snapshot_import_behavior}, + managed_agents::team_snapshot::{ + build_team_snapshot, decode_team_snapshot_json, decode_team_snapshot_png, + encode_team_snapshot_json, encode_team_snapshot_png, TeamSnapshot, + }, + managed_agents::{ + agent_snapshot::{build_snapshot, AgentSnapshot, AgentSnapshotMemoryEntry, MemoryLevel}, + load_managed_agents, load_personas, load_teams, load_teams_readonly, save_managed_agents, + save_personas, save_teams, AgentDefinition, ManagedAgentRecord, TeamRecord, + }, + relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, + util::now_iso, +}; + +/// Team snapshots have a combined 25 MiB JSON / 50 MiB PNG payload cap. +/// Every member is validated before any persistent write. +pub(crate) const MAX_TEAM_SNAPSHOT_JSON_BYTES: usize = 25 * 1024 * 1024; +pub(crate) const MAX_TEAM_SNAPSHOT_PNG_BYTES: usize = 50 * 1024 * 1024; + +const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47]; +const ZIP_MAGIC_PREFIX: [u8; 2] = [0x50, 0x4b]; +const LEGACY_TEAM_ERROR: &str = + "Legacy team files are no longer supported. Export a buzz-team-snapshot v1 .team.json or .team.png instead."; + +/// Decode a canonical team snapshot, rejecting retired flat team JSON and +/// persona-pack ZIP files with a migration-oriented error. +pub(crate) fn decode_team_snapshot_from_bytes(file_bytes: &[u8]) -> Result { + if file_bytes.starts_with(&PNG_MAGIC) { + if file_bytes.len() > MAX_TEAM_SNAPSHOT_PNG_BYTES { + return Err(format!( + "Team snapshot file is too large ({} MiB). PNG snapshots must be under 50 MiB.", + file_bytes.len() / (1024 * 1024) + )); + } + return decode_team_snapshot_png(file_bytes); + } + + if file_bytes.len() > MAX_TEAM_SNAPSHOT_JSON_BYTES { + return Err(format!( + "Team snapshot file is too large ({} MiB). JSON snapshots must be under 25 MiB.", + file_bytes.len() / (1024 * 1024) + )); + } + + // Detect the retired schema before attempting canonical deserialization so + // old `.team.json` attachments never look like malformed new snapshots. + let value: serde_json::Value = match serde_json::from_slice(file_bytes) { + Ok(value) => value, + Err(_) if file_bytes.starts_with(&ZIP_MAGIC_PREFIX) => { + return Err(LEGACY_TEAM_ERROR.to_string()); + } + Err(error) => return Err(format!("Invalid team snapshot JSON: {error}")), + }; + if value.get("format").and_then(serde_json::Value::as_str) + != Some(crate::managed_agents::team_snapshot::FORMAT_DISCRIMINATOR) + && value.get("version").and_then(serde_json::Value::as_u64) == Some(1) + && value.get("type").and_then(serde_json::Value::as_str) == Some("team") + { + return Err(LEGACY_TEAM_ERROR.to_string()); + } + + decode_team_snapshot_json(file_bytes) +} + +fn parse_format_is_png(format: &str) -> Result { + match format { + "json" | "" => Ok(false), + "png" => Ok(true), + other => Err(format!( + "Invalid format: {other:?} (expected 'json' or 'png')" + )), + } +} + +fn parse_memory_level(s: &str) -> Result { + match s { + "none" | "" => Ok(MemoryLevel::None), + "core" => Ok(MemoryLevel::Core), + "everything" => Ok(MemoryLevel::Everything), + other => Err(format!( + "Invalid memory_level: {other:?} (expected 'none', 'core', or 'everything')" + )), + } +} + +fn effective_avatar(member: &AgentSnapshot) -> Option { + member + .profile + .avatar_data_url + .clone() + .or_else(|| member.profile.avatar_url.clone()) +} + +/// Build a definition from a team member snapshot without consuming its memory. +fn definition_from_snapshot( + member: &AgentSnapshot, + keep_allowlist: bool, + now: &str, +) -> Result { + let behavior = resolve_snapshot_import_behavior( + member.definition.respond_to.as_deref(), + &member.definition.respond_to_allowlist, + member.definition.parallelism, + keep_allowlist, + )?; + let respond_to = (behavior.respond_to != crate::managed_agents::RespondTo::default()) + .then(|| behavior.respond_to.as_str().to_string()); + + Ok(AgentDefinition { + id: Uuid::new_v4().to_string(), + display_name: member.profile.display_name.trim().to_string(), + avatar_url: effective_avatar(member), + system_prompt: member.definition.system_prompt.clone().unwrap_or_default(), + runtime: member.definition.runtime.clone(), + model: member.definition.model.clone(), + provider: member.definition.provider.clone(), + name_pool: member.definition.name_pool.clone(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: Default::default(), + respond_to, + respond_to_allowlist: behavior.respond_to_allowlist, + parallelism: behavior.parallelism, + created_at: now.to_string(), + updated_at: now.to_string(), + }) +} + +pub(crate) fn build_import_definitions( + snapshot: &TeamSnapshot, + keep_allowlist: bool, + now: &str, +) -> Result, String> { + snapshot + .members + .iter() + .map(|member| definition_from_snapshot(member, keep_allowlist, now)) + .collect() +} + +/// Assemble the one new team record that references freshly built definitions. +pub(crate) fn build_import_team( + snapshot: &TeamSnapshot, + persona_ids: Vec, + now: &str, +) -> Result { + let name = snapshot.team.name.trim(); + if name.is_empty() { + return Err("Team snapshot name is empty.".to_string()); + } + + Ok(TeamRecord { + id: Uuid::new_v4().to_string(), + name: name.to_string(), + description: snapshot.team.description.clone(), + persona_ids, + instructions: snapshot.team.instructions.clone(), + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }) +} + +fn member_preview(member: &AgentSnapshot) -> TeamSnapshotMemberPreview { + TeamSnapshotMemberPreview { + display_name: member.profile.display_name.clone(), + system_prompt: member.definition.system_prompt.clone(), + avatar_url: effective_avatar(member), + has_source_allowlist: !member.definition.respond_to_allowlist.is_empty(), + source_allowlist_count: member.definition.respond_to_allowlist.len(), + } +} + +/// Preview metadata for one definition that will be imported with a team. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotMemberPreview { + pub display_name: String, + pub system_prompt: Option, + pub avatar_url: Option, + pub has_source_allowlist: bool, + pub source_allowlist_count: usize, +} + +/// Materialized team snapshot preview. No write happens before confirmation. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotImportPreview { + pub name: String, + pub description: Option, + pub instructions: Option, + pub members: Vec, + pub has_source_allowlist: bool, +} + +/// Confirmation input for a team snapshot import. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotImportConfirm { + pub file_bytes: Vec, + /// Applied uniformly to every member in v1. + pub keep_allowlist: bool, +} + +/// Per-member outcome reported after a confirmed team snapshot import. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotImportMemberResult { + pub display_name: String, + pub pubkey: String, + pub persona_id: String, + pub memory_written: usize, + pub memory_total: usize, + pub memory_errors: Vec, + pub profile_sync_error: Option, +} + +/// Result of a team snapshot import. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotImportResult { + pub team: TeamRecord, + pub persona_ids: Vec, + pub members: Vec, +} + +/// In-memory bytes for the native team sharing flow. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EncodedTeamSnapshotPayload { + pub file_bytes: Vec, + pub file_name: String, +} + +/// Per-member minted key material, assembled before entering the store lock. +struct MintedMember { + definition: AgentDefinition, + record: ManagedAgentRecord, + agent_keys: nostr::Keys, + pubkey: String, + auth_tag: Option, + display_name: String, + effective_avatar: Option, +} + +fn build_team_export_snapshot( + team: &TeamRecord, + personas: &[AgentDefinition], + records: &[ManagedAgentRecord], + memory_level: MemoryLevel, + memory_entries_by_persona: &std::collections::HashMap>, +) -> Result { + let members = team + .persona_ids + .iter() + .map(|id| { + let persona = personas + .iter() + .find(|persona| persona.id == *id) + .ok_or_else(|| { + format!("team {} references missing agent definition {id}", team.id) + })?; + + // Find the team instance for this persona (if any). + let instance = records.iter().find(|r| { + r.team_id.as_deref() == Some(&team.id) + && r.persona_id.as_deref() == Some(&persona.id) + }); + + // Use memory only when we have a live instance and a non-None level. + let (effective_level, entries) = match (instance, memory_level) { + (Some(_), level) if level != MemoryLevel::None => { + let entries = memory_entries_by_persona + .get(&persona.id) + .cloned() + .unwrap_or_default(); + (level, entries) + } + _ => (MemoryLevel::None, Vec::new()), + }; + + Ok(build_snapshot( + &persona.clone().into_agent_record(), + effective_level, + entries, + None, + )) + }) + .collect::, String>>()?; + Ok(build_team_snapshot(team, members)) +} + +async fn materialize_team_snapshot_bytes( + id: String, + is_png: bool, + memory_level: MemoryLevel, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let effective_memory_level = memory_level; + + let (team, personas, records) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let team = load_teams(&app)? + .into_iter() + .find(|team| team.id == id) + .ok_or_else(|| format!("team {id} not found"))?; + let personas = load_personas(&app)?; + let records = load_managed_agents(&app)?; + (team, personas, records) + }; + + // Fetch memory for each member that has a live instance, outside the lock. + let mut memory_entries_by_persona: std::collections::HashMap< + String, + Vec, + > = std::collections::HashMap::new(); + + if effective_memory_level != MemoryLevel::None { + for persona_id in &team.persona_ids { + let instance = records.iter().find(|r| { + r.team_id.as_deref() == Some(&team.id) + && r.persona_id.as_deref() == Some(persona_id.as_str()) + }); + if let Some(instance) = instance { + let listing = crate::commands::engrams::get_agent_memory( + instance.pubkey.clone(), + app.clone(), + state.clone(), + ) + .await?; + let mut entries = Vec::new(); + if let Some(core) = listing.core { + entries.push(AgentSnapshotMemoryEntry { + slug: core.slug, + body: core.body, + }); + } + if effective_memory_level == MemoryLevel::Everything { + for mem in listing.memories { + entries.push(AgentSnapshotMemoryEntry { + slug: mem.slug, + body: mem.body, + }); + } + } + memory_entries_by_persona.insert(persona_id.clone(), entries); + } + } + } + + let snapshot = build_team_export_snapshot( + &team, + &personas, + &records, + effective_memory_level, + &memory_entries_by_persona, + )?; + let slug = crate::util::slugify(&team.name, "team", 50); + let (file_bytes, file_name) = if is_png { + let bytes = encode_team_snapshot_png(&snapshot) + .map_err(|e| format!("Failed to encode .team.png: {e}"))?; + if bytes.len() > MAX_TEAM_SNAPSHOT_PNG_BYTES { + return Err( + "Team snapshot exceeds the 50 MiB size limit for .team.png files.".to_string(), + ); + } + (bytes, format!("{slug}.team.png")) + } else { + let bytes = encode_team_snapshot_json(&snapshot) + .map_err(|e| format!("Failed to encode .team.json: {e}"))?; + if bytes.len() > MAX_TEAM_SNAPSHOT_JSON_BYTES { + return Err( + "Team snapshot exceeds the 25 MiB size limit for .team.json files.".to_string(), + ); + } + (bytes, format!("{slug}.team.json")) + }; + Ok(EncodedTeamSnapshotPayload { + file_bytes, + file_name, + }) +} + +/// Export a team snapshot with optional member memory. +#[tauri::command] +pub async fn export_team_snapshot( + id: String, + format: String, + memory_level: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let is_png = parse_format_is_png(&format)?; + let memory_level = parse_memory_level(&memory_level)?; + let payload = + materialize_team_snapshot_bytes(id, is_png, memory_level, app.clone(), state).await?; + if is_png { + save_bytes_with_dialog( + &app, + &payload.file_name, + "PNG image", + &["png"], + &payload.file_bytes, + ) + .await + } else { + save_bytes_with_dialog( + &app, + &payload.file_name, + "Team snapshot", + &["json"], + &payload.file_bytes, + ) + .await + } +} + +/// Encode a team snapshot for the native send flow without opening a dialog. +#[tauri::command] +pub async fn encode_team_snapshot_for_send( + id: String, + format: String, + memory_level: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let memory_level = parse_memory_level(&memory_level)?; + materialize_team_snapshot_bytes(id, parse_format_is_png(&format)?, memory_level, app, state) + .await +} + +/// Decode a team snapshot into a confirmation preview without writing anything. +#[tauri::command] +pub async fn preview_team_snapshot_import( + file_bytes: Vec, + _file_name: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let snapshot = decode_team_snapshot_from_bytes(&file_bytes)?; + let members: Vec<_> = snapshot.members.iter().map(member_preview).collect(); + Ok(TeamSnapshotImportPreview { + name: snapshot.team.name, + description: snapshot.team.description, + instructions: snapshot.team.instructions, + has_source_allowlist: members.iter().any(|member| member.has_source_allowlist), + members, + }) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Import a team snapshot, minting full agent instances for every member. +/// +/// Phase sequence: +/// 1. Validate — decode the manifest and resolve all behavioral defaults. +/// Fail immediately if any member is invalid — zero writes. +/// 2. Mint — generate a fresh keypair + NIP-OA auth tag for EVERY member. +/// If ANY generation fails, return immediately — zero writes. +/// 3. Store — inside `managed_agents_store_lock`: write all `AgentDefinition`s +/// + all `ManagedAgentRecord`s (with `team_id` set) + `TeamRecord`. +/// Both store files are snapshotted (or noted absent) before the first +/// write. On any write error the pre-import state is restored — including +/// deleting a file that was absent, cleaning minted keyring entries, and +/// surfacing rollback failures alongside the original error. This makes +/// the store phase all-or-none for ordinary application errors; a process +/// crash between atomic file commits is NOT covered. +/// 4. Profile sync — for each member, call `sync_managed_agent_profile`. +/// Best-effort; errors are collected per member. +/// 5. Memory restore — for each member with non-empty snapshot memory, +/// publish each entry as a `kind:30174` engram event. Best-effort. +/// +/// Importing the same file twice yields two distinct teams with different +/// agent keypairs (same as individual agent import). +#[tauri::command] +pub async fn confirm_team_snapshot_import( + input: TeamSnapshotImportConfirm, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // ── Phase 1: validate (no I/O) ─────────────────────────────────────────── + let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?; + let now = now_iso(); + + // Resolve behavioral defaults for every member before any key generation. + let definitions = build_import_definitions(&snapshot, input.keep_allowlist, &now)?; + let persona_ids: Vec = definitions.iter().map(|d| d.id.clone()).collect(); + let imported_team = build_import_team(&snapshot, persona_ids.clone(), &now)?; + + // ── Phase 2: mint keys + auth tags (sync, outside lock) ───────────────── + // All mints must succeed before we enter the store. If any fails, zero writes. + let owner_pubkey_hex = { + let keys = state.signing_keys()?; + keys.public_key().to_hex() + }; + + let mut minted: Vec = Vec::with_capacity(snapshot.members.len()); + for (member, definition) in snapshot.members.iter().zip(definitions) { + let display_name = definition.display_name.clone(); + let effective_avatar_url = effective_avatar(member); + let respond_to_wire = definition.respond_to.clone(); + let minted_parallelism = definition.parallelism; + + let (agent_keys, private_key_nsec, pubkey, auth_tag) = { + let owner_keys = state.signing_keys()?; + let agent_keys = nostr::Keys::generate(); + let pubkey = agent_keys.public_key().to_hex(); + let private_key_nsec = { + use nostr::ToBech32; + agent_keys + .secret_key() + .to_bech32() + .map_err(|e| format!("failed to encode agent private key: {e}"))? + }; + // NIP-OA auth tag: bridge nostr 0.37 → 0.36 (buzz-sdk) via hex round-trip. + let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|e| format!("failed to bridge owner keys: {e}"))?; + let compat_agent = nostr::PublicKey::from_hex(&pubkey) + .map_err(|e| format!("failed to bridge agent pubkey: {e}"))?; + let auth_tag = Some( + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "") + .map_err(|e| format!("failed to compute NIP-OA auth tag: {e}"))?, + ); + (agent_keys, private_key_nsec, pubkey, auth_tag) + }; + + // Build the ManagedAgentRecord for this member. + let record = ManagedAgentRecord { + pubkey: pubkey.clone(), + name: display_name.clone(), + display_name: None, + slug: None, + persona_id: Some(definition.id.clone()), + private_key_nsec: private_key_nsec.clone(), + auth_tag: auth_tag.clone(), + relay_url: String::new(), + avatar_url: effective_avatar_url.clone(), + acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: member.definition.idle_timeout_seconds, + max_turn_duration_seconds: member.definition.max_turn_duration_seconds, + parallelism: minted_parallelism + .unwrap_or(crate::managed_agents::DEFAULT_AGENT_PARALLELISM), + system_prompt: member.definition.system_prompt.clone(), + model: member.definition.model.clone(), + provider: member.definition.provider.clone(), + persona_source_version: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: Some(imported_team.id.clone()), + persona_team_dir: None, + persona_name_in_team: None, + created_at: now.clone(), + updated_at: now.clone(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: { + use crate::managed_agents::RespondTo; + respond_to_wire + .as_deref() + .map(RespondTo::parse_wire) + .transpose()? + .unwrap_or_default() + }, + respond_to_allowlist: definition.respond_to_allowlist.clone(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: respond_to_wire.clone(), + definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), + definition_parallelism: minted_parallelism, + relay_mesh: None, + runtime: member.definition.runtime.clone(), + name_pool: member.definition.name_pool.clone(), + }; + + minted.push(MintedMember { + definition, + record, + agent_keys, + pubkey, + auth_tag, + display_name, + effective_avatar: effective_avatar_url, + }); + } + + // ── Phase 3: store (sync, inside lock) ────────────────────────────────── + let team = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + // Guard against duplicate pubkeys (astronomically unlikely). + let existing_records = load_managed_agents(&app)?; + for m in &minted { + if existing_records.iter().any(|r| r.pubkey == m.pubkey) { + return Err(format!( + "generated pubkey {} already exists — retry", + m.pubkey + )); + } + } + + // Snapshot both store files for rollback on partial write failure. + // Distinguish "file exists with content" from "file absent" so rollback + // can delete a file created by the import rather than leaving orphaned + // records. + let agents_store_path = crate::managed_agents::storage::managed_agents_store_path(&app)?; + let agents_store_snapshot = match std::fs::read(&agents_store_path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(format!("failed to snapshot agent store: {e}")), + }; + let teams_store_path = crate::managed_agents::teams_store_path(&app)?; + let teams_store_snapshot = match std::fs::read(&teams_store_path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(format!("failed to snapshot teams store: {e}")), + }; + + // Pre-read teams via the read-only loader BEFORE any agent commits. + // This avoids load_teams()'s write-on-load side effect (teams.rs:165-166 + // saves whenever the file is absent or built-ins changed). A failure here + // aborts cleanly — zero writes have occurred. + let mut teams = load_teams_readonly(&teams_store_path)?; + + // Collect minted pubkeys for keyring cleanup on rollback. + let minted_pubkeys: Vec<&str> = minted.iter().map(|m| m.pubkey.as_str()).collect(); + + // Restore the agent store to pre-import state and clean minted keyring + // entries. Returns the original error, extended with rollback details. + let rollback_agents = |original_err: String| -> String { + let mut errors = vec![original_err]; + // Clean minted keyring entries. + for pubkey in &minted_pubkeys { + if let Err(e) = crate::managed_agents::storage::try_delete_agent_key(pubkey) { + errors.push(format!("keyring cleanup {pubkey}: {e}")); + } + } + // Restore agent store file. + let restore = match &agents_store_snapshot { + Some(bytes) => crate::managed_agents::storage::atomic_write_json_restricted( + &agents_store_path, + bytes, + ), + None => match std::fs::remove_file(&agents_store_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other.map_err(|e| e.to_string()), + }, + }; + if let Err(e) = restore { + errors.push(format!("agent store restore: {e}")); + } + if errors.len() == 1 { + errors.into_iter().next().unwrap() + } else { + errors.join("; ") + } + }; + + // Write all definitions. + let mut personas = load_personas(&app)?; + for m in &minted { + personas.push(m.definition.clone()); + } + if let Err(e) = save_personas(&app, &personas) { + return Err(rollback_agents(e)); + } + + // Write all managed-agent records. + let mut records = existing_records; + for m in &minted { + records.push(m.record.clone()); + } + if let Err(e) = save_managed_agents(&app, &records) { + return Err(rollback_agents(e)); + } + + // Write the team record. `teams` was pre-loaded via the read-only + // loader before any agent commits, so a read/parse failure already + // aborted before any phase-3 write. save_teams sorts and persists. + teams.push(imported_team.clone()); + if let Err(e) = save_teams(&app, &teams) { + let err = rollback_agents(e); + // Also restore teams store. + let teams_restore = match &teams_store_snapshot { + Some(bytes) => { + crate::managed_agents::storage::atomic_write_json(&teams_store_path, bytes) + } + None => match std::fs::remove_file(&teams_store_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other.map_err(|e| e.to_string()), + }, + }; + return Err(match teams_restore { + Ok(()) => err, + Err(teams_err) => format!("{err}; teams store restore: {teams_err}"), + }); + } + + // All writes committed — safe to update in-memory state. + for m in &minted { + crate::commands::personas::retain_persona_pending(&app, &state, &m.definition); + } + for m in &minted { + retain_agent_pending(&app, &state, &m.record); + } + crate::commands::teams::retain_team_pending(&app, &state, &imported_team); + + crate::managed_agents::try_regenerate_nest(&app); + let _ = app.emit("agents-data-changed", ()); + + imported_team + }; + + // ── Phase 4 & 5: profile sync + memory restore (async, outside lock) ──── + let relay_ws = relay_ws_url_with_override(&state); + let mut member_results: Vec = Vec::with_capacity(minted.len()); + + for (m, snap_member) in minted.iter().zip(snapshot.members.iter()) { + let relay_url = effective_agent_relay_url(&m.record.relay_url, &relay_ws); + + // Phase 4: profile sync (best-effort). + let profile_sync_error = sync_managed_agent_profile( + &state, + &relay_url, + &m.agent_keys, + &m.display_name, + m.effective_avatar.as_deref(), + m.auth_tag.as_deref(), + ) + .await + .err(); + + // Phase 5: memory restore (best-effort). + let memory_total = snap_member.memory.entries.len(); + let mut memory_written = 0usize; + let mut memory_errors: Vec = Vec::new(); + + if memory_total > 0 { + let owner_pubkey = nostr::PublicKey::from_hex(&owner_pubkey_hex) + .map_err(|e| format!("failed to parse owner pubkey: {e}"))?; + let base_ts = nostr::Timestamp::now().as_secs(); + + for (idx, entry) in snap_member.memory.entries.iter().enumerate() { + let body = if entry.slug == buzz_core_pkg::engram::CORE_SLUG { + buzz_core_pkg::engram::Body::Core { + profile: entry.body.clone(), + } + } else { + buzz_core_pkg::engram::Body::Memory { + slug: entry.slug.clone(), + value: Some(entry.body.clone()), + } + }; + + let created_at = base_ts + idx as u64; + match buzz_core_pkg::engram::build_event( + &m.agent_keys, + &owner_pubkey, + &body, + created_at, + ) { + Ok(event) => { + use nostr::JsonUtil; + let event_json = event.as_json().into_bytes(); + let url = + format!("{}/events", crate::relay::relay_http_base_url(&relay_url)); + match submit_engram_event( + &state, + &m.agent_keys, + &event_json, + &url, + m.auth_tag.as_deref(), + ) + .await + { + Ok(()) => memory_written += 1, + Err(e) => memory_errors.push(format!("slug {:?}: {e}", entry.slug)), + } + } + Err(e) => { + memory_errors.push(format!("slug {:?}: build failed: {e}", entry.slug)); + } + } + } + } + + member_results.push(TeamSnapshotImportMemberResult { + display_name: m.display_name.clone(), + pubkey: m.pubkey.clone(), + persona_id: m.definition.id.clone(), + memory_written, + memory_total, + memory_errors, + profile_sync_error, + }); + } + + Ok(TeamSnapshotImportResult { + team, + persona_ids, + members: member_results, + }) +} + +/// Inline retention for the managed-agent kind:30177 event — mirrors +/// `commands::personas::snapshot::import::retain_agent_pending`. +fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { + use crate::managed_agents::{ + agent_events::{agent_event_content, build_agent_event}, + managed_agents_base_dir, + persona_events::monotonic_created_at, + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + }; + use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + use nostr::JsonUtil; + + let result = (|| -> Result<(), String> { + let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let content = serde_json::to_string(&agent_event_content(record)) + .map_err(|e| format!("failed to serialize agent content: {e}"))?; + let (owner_pubkey, event) = { + let keys = state.signing_keys()?; + let owner_pubkey = keys.public_key().to_hex(); + let existing = + get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; + if existing.as_ref().is_some_and(|row| row.content == content) { + return Ok(()); + } + let event = build_agent_event(record)? + .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) + .sign_with_keys(&keys) + .map_err(|e| format!("failed to sign agent event: {e}"))?; + (owner_pubkey, event) + }; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner_pubkey, + d_tag: record.pubkey.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: team-snapshot-import retain-agent: {e}"); + } +} + +/// POST a pre-built signed engram event to the relay, authenticating as the +/// new agent. Mirrors the same helper in `snapshot::import`. +async fn submit_engram_event( + state: &AppState, + agent_keys: &nostr::Keys, + event_json: &[u8], + url: &str, + auth_tag: Option<&str>, +) -> Result<(), String> { + use crate::relay::build_nip98_auth_header_for_keys; + use reqwest::Method; + + let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?; + let mut request = state + .http_client + .post(url) + .header("Authorization", auth) + .header("Content-Type", "application/json"); + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } + let response = request + .body(event_json.to_vec()) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + if !response.status().is_success() { + let msg = crate::relay::relay_error_message(response).await; + return Err(format!("relay rejected engram: {msg}")); + } + + let body = response + .text() + .await + .map_err(|e| format!("failed to read relay response: {e}"))?; + let parsed: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("relay response not JSON: {e}"))?; + let accepted = parsed + .get("accepted") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !accepted { + let message = parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + return Err(format!("relay rejected engram: {message}")); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs new file mode 100644 index 0000000000..ca7dc61830 --- /dev/null +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -0,0 +1,726 @@ +use super::*; +use crate::managed_agents::{ + agent_snapshot::{ + AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotMemoryEntry, + AgentSnapshotProfile, + }, + team_snapshot::{TeamSnapshotMeta, FORMAT_DISCRIMINATOR, FORMAT_VERSION}, +}; + +fn member(name: &str) -> AgentSnapshot { + AgentSnapshot { + format: crate::managed_agents::agent_snapshot::FORMAT_DISCRIMINATOR.to_string(), + version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: name.to_string(), + system_prompt: Some(format!("{name} prompt")), + runtime: Some("goose".to_string()), + model: None, + provider: None, + parallelism: Some(2), + respond_to: Some("allowlist".to_string()), + respond_to_allowlist: vec!["ab".repeat(32)], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: name.to_string(), + about: None, + avatar_data_url: None, + avatar_url: Some(format!("https://example.test/{name}.png")), + }, + memory: AgentSnapshotMemory { + level: MemoryLevel::None, + entries: vec![], + }, + } +} + +fn snapshot(members: Vec) -> TeamSnapshot { + TeamSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + team: TeamSnapshotMeta { + name: "Review Team".to_string(), + description: Some("Reviews changes".to_string()), + instructions: Some("Be thorough.".to_string()), + }, + members, + } +} + +#[test] +fn team_export_round_trip_preserves_team_and_excludes_member_memory() { + let definitions = vec![ + AgentDefinition { + id: "alice".to_string(), + display_name: "Alice".to_string(), + avatar_url: None, + system_prompt: "Alice prompt".to_string(), + runtime: Some("goose".to_string()), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: Default::default(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "now".to_string(), + updated_at: "now".to_string(), + }, + AgentDefinition { + id: "bob".to_string(), + display_name: "Bob".to_string(), + avatar_url: None, + system_prompt: "Bob prompt".to_string(), + runtime: Some("goose".to_string()), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: Default::default(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "now".to_string(), + updated_at: "now".to_string(), + }, + ]; + let team = TeamRecord { + id: "review".to_string(), + name: "Review Team".to_string(), + description: Some("Reviews changes".to_string()), + instructions: Some("Be thorough.".to_string()), + persona_ids: vec!["alice".to_string(), "bob".to_string()], + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "now".to_string(), + updated_at: "now".to_string(), + }; + + // Export with no instances and MemoryLevel::None — memory stays empty. + let bytes = encode_team_snapshot_json( + &build_team_export_snapshot( + &team, + &definitions, + &[], + MemoryLevel::None, + &std::collections::HashMap::new(), + ) + .unwrap(), + ) + .unwrap(); + let decoded = decode_team_snapshot_from_bytes(&bytes).unwrap(); + + assert_eq!(decoded.team.name, "Review Team"); + assert_eq!(decoded.team.description.as_deref(), Some("Reviews changes")); + assert_eq!(decoded.team.instructions.as_deref(), Some("Be thorough.")); + assert_eq!(decoded.members.len(), 2); + assert!(decoded.members.iter().all(|member| { + member.memory.level == MemoryLevel::None && member.memory.entries.is_empty() + })); +} + +#[test] +fn team_export_with_instance_and_memory_level_uses_supplied_entries() { + let definitions = vec![AgentDefinition { + id: "alice".to_string(), + display_name: "Alice".to_string(), + avatar_url: None, + system_prompt: "Alice prompt".to_string(), + runtime: Some("goose".to_string()), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: Default::default(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "now".to_string(), + updated_at: "now".to_string(), + }]; + let team = TeamRecord { + id: "t1".to_string(), + name: "Team".to_string(), + description: None, + instructions: None, + persona_ids: vec!["alice".to_string()], + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "now".to_string(), + updated_at: "now".to_string(), + }; + + // Build a fake instance record tied to this team+persona. + let instance = ManagedAgentRecord { + pubkey: "a".repeat(64), + name: "Alice".to_string(), + display_name: None, + slug: None, + persona_id: Some("alice".to_string()), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: crate::managed_agents::DEFAULT_AGENT_PARALLELISM, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: Default::default(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: Some("t1".to_string()), + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".to_string(), + updated_at: "now".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: crate::managed_agents::RespondTo::default(), + respond_to_allowlist: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + relay_mesh: None, + runtime: None, + name_pool: vec![], + }; + + let mut memory_map = std::collections::HashMap::new(); + memory_map.insert( + "alice".to_string(), + vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "Alice is an expert reviewer.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/pref".to_string(), + body: "prefers concise answers".to_string(), + }, + ], + ); + + // MemoryLevel::Everything with an instance → entries should appear. + let snap = build_team_export_snapshot( + &team, + &definitions, + std::slice::from_ref(&instance), + MemoryLevel::Everything, + &memory_map, + ) + .unwrap(); + assert_eq!(snap.members[0].memory.level, MemoryLevel::Everything); + assert_eq!(snap.members[0].memory.entries.len(), 2); + + // MemoryLevel::None → entries stripped regardless. + let snap_none = build_team_export_snapshot( + &team, + &definitions, + std::slice::from_ref(&instance), + MemoryLevel::None, + &memory_map, + ) + .unwrap(); + assert_eq!(snap_none.members[0].memory.level, MemoryLevel::None); + assert!(snap_none.members[0].memory.entries.is_empty()); + + // No instance → entries stripped even with Everything level. + let snap_no_instance = build_team_export_snapshot( + &team, + &definitions, + &[], + MemoryLevel::Everything, + &memory_map, + ) + .unwrap(); + assert_eq!(snap_no_instance.members[0].memory.level, MemoryLevel::None); + assert!(snap_no_instance.members[0].memory.entries.is_empty()); +} + +#[test] +fn team_import_definitions_are_built_for_all_members() { + let mut memory_bearing = member("Alice"); + memory_bearing.memory = AgentSnapshotMemory { + level: MemoryLevel::Everything, + entries: vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "must remain inert".to_string(), + }], + }; + let decoded = decode_team_snapshot_from_bytes( + &encode_team_snapshot_json(&snapshot(vec![memory_bearing, member("Bob")])).unwrap(), + ) + .unwrap(); + let definitions = build_import_definitions(&decoded, false, "now").unwrap(); + let team = build_import_team( + &decoded, + definitions + .iter() + .map(|definition| definition.id.clone()) + .collect(), + "now", + ) + .unwrap(); + + assert_eq!(definitions.len(), 2); + assert_eq!(team.persona_ids.len(), 2); + assert_eq!(team.instructions.as_deref(), Some("Be thorough.")); + assert_eq!( + team.persona_ids, + definitions + .iter() + .map(|definition| definition.id.clone()) + .collect::>() + ); + assert!(definitions.iter().all(|definition| { + definition.id.len() == 36 + && definition.source_team.is_none() + && definition.env_vars.is_empty() + && definition.respond_to_allowlist.is_empty() + })); + assert_eq!(definitions[0].system_prompt, "Alice prompt"); +} + +#[test] +fn team_import_keeps_or_clears_every_member_allowlist_with_one_toggle() { + let source = snapshot(vec![member("Alice"), member("Bob")]); + let kept = build_import_definitions(&source, true, "now").unwrap(); + let cleared = build_import_definitions(&source, false, "now").unwrap(); + + assert!(kept.iter().all(|definition| { + definition.respond_to.as_deref() == Some("allowlist") + && definition.respond_to_allowlist == vec!["ab".repeat(32)] + })); + assert!(cleared.iter().all(|definition| { + definition.respond_to.is_none() && definition.respond_to_allowlist.is_empty() + })); +} + +#[test] +fn legacy_flat_team_and_pack_zip_return_actionable_error() { + let old_flat = br#"{"version":1,"type":"team","name":"Old"}"#; + for bytes in [old_flat.as_slice(), b"PK\x05\x06empty-pack".as_slice()] { + let error = decode_team_snapshot_from_bytes(bytes).unwrap_err(); + assert_eq!(error, LEGACY_TEAM_ERROR); + } +} + +#[test] +fn canonical_team_json_is_accepted_without_extension_case_policy() { + let bytes = encode_team_snapshot_json(&snapshot(vec![member("Alice")])).unwrap(); + // Preview/confirm intentionally decode content rather than file names, so + // canonical lowercase and uppercase extensions reach this same safe path. + assert!(decode_team_snapshot_from_bytes(&bytes).is_ok()); +} + +#[test] +fn parse_memory_level_round_trips_all_variants() { + assert_eq!(parse_memory_level("none").unwrap(), MemoryLevel::None); + assert_eq!(parse_memory_level("").unwrap(), MemoryLevel::None); + assert_eq!(parse_memory_level("core").unwrap(), MemoryLevel::Core); + assert_eq!( + parse_memory_level("everything").unwrap(), + MemoryLevel::Everything + ); + assert!(parse_memory_level("bad").is_err()); +} + +// ── Rollback pattern tests ───────────────────────────────────────────── +// +// These test the file-level rollback mechanics used by confirm_team_snapshot_import +// Phase 3: snapshot files (or note absent), attempt writes, restore on failure. +// The Tauri AppHandle is not available in unit tests, so we exercise the same +// atomic_write + snapshot + restore operations directly on tempdir files. + +#[cfg(unix)] +#[test] +fn rollback_restores_existing_agent_store_after_failed_write() { + let dir = tempfile::tempdir().unwrap(); + let agents_path = dir.path().join("managed-agents.json"); + let original = b"[{\"pubkey\":\"existing\"}]"; + std::fs::write(&agents_path, original).unwrap(); + + // Snapshot — should succeed for existing file. + let snapshot = match std::fs::read(&agents_path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => panic!("unexpected read error: {e}"), + }; + assert!(snapshot.is_some()); + + // Simulate a write that changed the file. + std::fs::write(&agents_path, b"[{\"pubkey\":\"imported\"}]").unwrap(); + + // Rollback: restore from snapshot. + let restore = match &snapshot { + Some(bytes) => { + crate::managed_agents::storage::atomic_write_json_restricted(&agents_path, bytes) + } + None => match std::fs::remove_file(&agents_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other.map_err(|e| e.to_string()), + }, + }; + assert!(restore.is_ok()); + assert_eq!(std::fs::read(&agents_path).unwrap(), original); +} + +#[cfg(unix)] +#[test] +fn rollback_deletes_file_that_was_absent_before_import() { + let dir = tempfile::tempdir().unwrap(); + let agents_path = dir.path().join("managed-agents.json"); + + // Snapshot — file does not exist. + let snapshot = match std::fs::read(&agents_path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => panic!("unexpected read error: {e}"), + }; + assert!(snapshot.is_none()); + + // Simulate a write that created the file. + std::fs::write(&agents_path, b"[{\"pubkey\":\"orphan\"}]").unwrap(); + assert!(agents_path.exists()); + + // Rollback: remove the file (restore "absent" state). + let restore = match &snapshot { + Some(bytes) => { + crate::managed_agents::storage::atomic_write_json_restricted(&agents_path, bytes) + } + None => match std::fs::remove_file(&agents_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other.map_err(|e| e.to_string()), + }, + }; + assert!(restore.is_ok()); + assert!( + !agents_path.exists(), + "absent-store rollback must delete the file" + ); +} + +#[cfg(unix)] +#[test] +fn rollback_absent_file_treats_already_absent_as_success() { + let dir = tempfile::tempdir().unwrap(); + let agents_path = dir.path().join("managed-agents.json"); + + // File was absent before import and is still absent (e.g. the write that + // would have created it also failed). Rollback must succeed. + let restore = match std::fs::remove_file(&agents_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other.map_err(|e| e.to_string()), + }; + assert!( + restore.is_ok(), + "removing an already-absent file must succeed" + ); +} + +#[cfg(unix)] +#[test] +fn snapshot_read_error_on_unreadable_file_is_surfaced() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let agents_path = dir.path().join("managed-agents.json"); + std::fs::write(&agents_path, b"content").unwrap(); + std::fs::set_permissions(&agents_path, std::fs::Permissions::from_mode(0o000)).unwrap(); + + // A non-NotFound read error must be surfaced, not collapsed to None. + let result = match std::fs::read(&agents_path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("failed to snapshot agent store: {e}")), + }; + + // Restore permissions for cleanup. + std::fs::set_permissions(&agents_path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + assert!( + result.is_err(), + "permission error must not be collapsed to None" + ); +} + +#[cfg(unix)] +#[test] +fn rollback_aggregates_multiple_errors() { + // Simulate the error aggregation pattern used in the rollback closure. + let original_err = "save_teams failed".to_string(); + let mut errors = vec![original_err.clone()]; + + // Simulate a keyring cleanup failure. + errors.push("keyring cleanup pubkey-1: keyring unreachable".to_string()); + + // Simulate a disk restore failure. + errors.push("agent store restore: permission denied".to_string()); + + let combined = errors.join("; "); + assert!(combined.contains("save_teams failed")); + assert!(combined.contains("keyring cleanup pubkey-1")); + assert!(combined.contains("agent store restore")); + assert_eq!( + combined.matches(';').count(), + 2, + "three errors joined by two semicolons" + ); +} + +/// Full-sequence failure injection at the teams-write boundary. +/// +/// Exercises the complete confirm_team_snapshot_import phase-3 rollback path: +/// snapshot both stores → write agents (succeeds) → write teams (fails via +/// read-only dir) → rollback keyring + agents store + teams store → assert +/// exact pre-import disk state. +/// +/// Keyring cleanup is exercised via a result-returning closure that mirrors +/// `try_delete_agent_key`'s signature. The real keyring seam is not called +/// here because `system-keyring` (default feature) accesses the OS keychain, +/// which blocks on authorization prompts in headless/CI environments. +/// The `try_delete_agent_key` function itself is integration-tested through +/// the `#[ignore]` keychain tests in `secret_store.rs`. +#[cfg(unix)] +#[test] +fn full_rollback_at_teams_boundary_existing_agents_store() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let agents_path = dir.path().join("managed-agents.json"); + let teams_path = dir.path().join("teams.json"); + + // Pre-import: agents store exists, teams store absent. + let original_agents = b"[{\"pubkey\":\"pre-existing\"}]"; + std::fs::write(&agents_path, original_agents).unwrap(); + + // Snapshot both stores (mirrors production NotFound-aware reads). + let agents_snap = match std::fs::read(&agents_path) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => panic!("agents snapshot: {e}"), + }; + let teams_snap = match std::fs::read(&teams_path) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => panic!("teams snapshot: {e}"), + }; + assert!(agents_snap.is_some()); + assert!(teams_snap.is_none()); + + // Phase-3 write 1: agents store committed. + crate::managed_agents::storage::atomic_write_json_restricted( + &agents_path, + b"[{\"pubkey\":\"imported\"}]", + ) + .unwrap(); + assert_ne!( + std::fs::read(&agents_path).unwrap().as_slice(), + original_agents, + "agents store must be changed by phase-3 write" + ); + + // Phase-3 write 2: teams write FAILS (read-only dir injection). + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap(); + let teams_err = + crate::managed_agents::storage::atomic_write_json(&teams_path, b"[{\"id\":\"team-1\"}]"); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(teams_err.is_err(), "teams write must fail in read-only dir"); + + // Full rollback (mirrors production rollback_agents + teams restore). + // Keyring cleanup: use a test-safe closure returning Ok(()) — the same + // contract as try_delete_agent_key on a no-keyring-backend host. + let minted_pubkeys = ["minted-aaa", "minted-bbb"]; + let mut errors = vec![teams_err.unwrap_err()]; + let try_delete_key = |_pk: &str| -> Result<(), String> { Ok(()) }; + + for pk in &minted_pubkeys { + if let Err(e) = try_delete_key(pk) { + errors.push(format!("keyring cleanup {pk}: {e}")); + } + } + let agents_restore = match &agents_snap { + Some(bytes) => { + crate::managed_agents::storage::atomic_write_json_restricted(&agents_path, bytes) + } + None => match std::fs::remove_file(&agents_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other.map_err(|e| e.to_string()), + }, + }; + if let Err(e) = agents_restore { + errors.push(format!("agent store restore: {e}")); + } + let teams_restore = match &teams_snap { + Some(bytes) => crate::managed_agents::storage::atomic_write_json(&teams_path, bytes), + None => match std::fs::remove_file(&teams_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other.map_err(|e| e.to_string()), + }, + }; + if let Err(e) = teams_restore { + errors.push(format!("teams store restore: {e}")); + } + + // Exact pre-import disk state restored. + assert_eq!( + std::fs::read(&agents_path).unwrap(), + original_agents, + "agents store must be restored to original content" + ); + assert!(!teams_path.exists(), "teams store must remain absent"); + assert_eq!( + errors.len(), + 1, + "only the teams-write error; keyring + disk rollback succeeded" + ); +} + +/// Variant: agents store was absent before import (fresh install). +/// +/// Exercises the NEW production control flow after the read-only loader fix: +/// load_teams_readonly (pre-commit, no write) → agents commit (creates file) → +/// save_teams fails → full rollback (keyring + delete created agents file + +/// teams absent-restore) → both files absent. +/// +/// This specifically tests the fix for the Thufir-found bypass where +/// `load_teams()` was secretly a writer on absent files — a failure inside +/// that hidden write would `?`-return without calling `rollback_agents`. +/// With `load_teams_readonly` pre-commit, that path no longer exists. +#[cfg(unix)] +#[test] +fn full_rollback_at_teams_boundary_absent_agents_store() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let agents_path = dir.path().join("managed-agents.json"); + let teams_path = dir.path().join("teams.json"); + + // Pre-import: BOTH stores absent (fresh install). + let agents_snap = match std::fs::read(&agents_path) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => panic!("agents snapshot: {e}"), + }; + let teams_snap = match std::fs::read(&teams_path) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => panic!("teams snapshot: {e}"), + }; + assert!(agents_snap.is_none()); + assert!(teams_snap.is_none()); + + // Pre-read teams via the read-only loader (mirrors production pre-commit). + // On a fresh install with both files absent, this returns the merged + // built-ins without writing. File must still be absent after the call. + let teams = crate::managed_agents::load_teams_readonly(&teams_path).unwrap(); + assert!( + !teams_path.exists(), + "load_teams_readonly must not create the file" + ); + + // Phase-3 write 1: import CREATES agents store. + std::fs::write(&agents_path, b"[{\"pubkey\":\"orphan\"}]").unwrap(); + assert!(agents_path.exists()); + + // Phase-3 write 2: save_teams FAILS (read-only dir injection). + // In production this is save_teams(&app, &teams) — we model the same + // atomic_write_json call that save_teams delegates to. + let mut teams_to_save = teams; + teams_to_save.push(crate::managed_agents::TeamRecord { + id: "team-1".to_string(), + name: "Imported".to_string(), + description: None, + instructions: None, + persona_ids: vec![], + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "now".to_string(), + updated_at: "now".to_string(), + }); + let payload = serde_json::to_vec_pretty(&teams_to_save).unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap(); + let teams_err = crate::managed_agents::storage::atomic_write_json(&teams_path, &payload); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(teams_err.is_err()); + + // Full rollback: keyring cleanup (test-safe) + delete created agents file + // + teams absent-restore. + let mut errors = vec![teams_err.unwrap_err()]; + let try_delete_key = |_pk: &str| -> Result<(), String> { Ok(()) }; + if let Err(e) = try_delete_key("orphan") { + errors.push(format!("keyring cleanup: {e}")); + } + let agents_restore = match &agents_snap { + None => match std::fs::remove_file(&agents_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other.map_err(|e| e.to_string()), + }, + Some(bytes) => { + crate::managed_agents::storage::atomic_write_json_restricted(&agents_path, bytes) + } + }; + if let Err(e) = agents_restore { + errors.push(format!("agent store restore: {e}")); + } + let teams_restore = match &teams_snap { + None => match std::fs::remove_file(&teams_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other.map_err(|e| e.to_string()), + }, + Some(bytes) => crate::managed_agents::storage::atomic_write_json(&teams_path, bytes), + }; + if let Err(e) = teams_restore { + errors.push(format!("teams store restore: {e}")); + } + + // Exact pre-import state: both files absent. + assert!( + !agents_path.exists(), + "rollback must delete file created by import on fresh install" + ); + assert!(!teams_path.exists()); + assert_eq!(errors.len(), 1, "only the teams-write error"); +} diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 3666d21bdd..ea9a6a4958 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -1,14 +1,11 @@ -use tauri::{AppHandle, State}; +use tauri::AppHandle; use uuid::Uuid; -use super::export_util::save_json_with_dialog; use crate::{ app_state::AppState, managed_agents::{ - delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active, - import_team_from_directory as do_import_team, load_personas, load_teams, parse_team_json, - save_teams, sync_team_from_dir as do_sync_team, try_regenerate_nest, CreateTeamRequest, - ParsedTeamPreview, SyncResult, TeamRecord, UpdateTeamRequest, + delete_team_with_cascade, ensure_persona_ids_are_active, load_personas, load_teams, + save_teams, try_regenerate_nest, CreateTeamRequest, TeamRecord, UpdateTeamRequest, }, util::now_iso, }; @@ -40,7 +37,7 @@ fn trim_optional(value: Option) -> Option { /// Unlike `retain_managed_agent_pending`, this has no projection-equality /// short-circuit: teams have no start/stop runtime churn, so a republish only /// happens on an actual user edit. The guard is intentionally omitted. -fn retain_team_pending(app: &AppHandle, state: &AppState, team: &TeamRecord) { +pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &TeamRecord) { use crate::managed_agents::{ managed_agents_base_dir, persona_events::monotonic_created_at, @@ -158,6 +155,7 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result(); let name = trim_required(&input.name, "Team name")?; let description = trim_optional(input.description); + let instructions = trim_optional(input.instructions); let now = now_iso(); let _store_guard = state @@ -171,6 +169,7 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result Result(); let name = trim_required(&input.name, "Team name")?; let description = trim_optional(input.description); + let instructions = trim_optional(input.instructions); let _store_guard = state .managed_agents_store_lock @@ -212,6 +212,7 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result Result<(), String> { .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } - -#[tauri::command] -pub async fn install_team_from_directory( - app: AppHandle, - path: String, - symlink: Option, -) -> Result { - use tauri::Manager; - tokio::task::spawn_blocking(move || { - let state = app.state::(); - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let source = std::path::PathBuf::from(&path); - if !source.is_dir() { - return Err(format!("team path is not a directory: {path}")); - } - let result = do_import_team(&app, &source, symlink.unwrap_or(false))?; - try_regenerate_nest(&app); - Ok(result) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - -#[tauri::command] -pub async fn sync_team_directory(app: AppHandle, team_id: String) -> Result { - use tauri::Manager; - tokio::task::spawn_blocking(move || { - let state = app.state::(); - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let result = do_sync_team(&app, &team_id)?; - try_regenerate_nest(&app); - Ok(result) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - -#[tauri::command] -pub async fn pick_team_directory(app: AppHandle) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - let path = app.dialog().file().blocking_pick_folder(); - Ok(path.map(|p| p.to_string())) -} - -const MAX_TEAM_JSON_BYTES: usize = 5 * 1024 * 1024; - -#[tauri::command] -pub async fn export_team_to_json( - id: String, - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - // Load team and personas under lock, then drop lock before dialog. - let (team, personas) = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let teams = load_teams(&app)?; - let team = teams - .into_iter() - .find(|t| t.id == id) - .ok_or_else(|| format!("team {id} not found"))?; - let personas = load_personas(&app)?; - (team, personas) - }; - - let json_bytes = encode_team_json(&team, &personas)?; - - let slug = crate::util::slugify(&team.name, "team", 50); - let filename = format!("{slug}.team.json"); - save_json_with_dialog(&app, &filename, &json_bytes).await -} - -/// Max zip size for pack imports via team import (100 MB, same as persona zip limit). -const MAX_TEAM_ZIP_BYTES: usize = 100 * 1024 * 1024; - -#[tauri::command] -pub fn parse_team_file( - file_bytes: Vec, - _file_name: String, -) -> Result { - if file_bytes.is_empty() { - return Err("File is empty.".to_string()); - } - - // Detect zip files (persona packs) BEFORE the JSON size check — zips can be larger. - if file_bytes.len() >= 4 && file_bytes[..4] == [0x50, 0x4B, 0x03, 0x04] { - if file_bytes.len() > MAX_TEAM_ZIP_BYTES { - return Err("ZIP file is too large (max 100 MB).".to_string()); - } - return parse_team_from_pack_zip(&file_bytes); - } - - if file_bytes.len() > MAX_TEAM_JSON_BYTES { - return Err("File is too large (max 5 MB).".to_string()); - } - - parse_team_json(&file_bytes) -} - -/// Parse a persona pack zip as a team: pack name → team name, personas → members. -fn parse_team_from_pack_zip(zip_bytes: &[u8]) -> Result { - use crate::managed_agents::TeamPersonaPreview; - - // Extract to tempdir and resolve the pack directly — gives us structured - // access to pack name without parsing formatted strings. - let tmp = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?; - let cursor = std::io::Cursor::new(zip_bytes); - let mut archive = - zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid ZIP archive: {e}"))?; - - let max_decompressed: usize = 100 * 1024 * 1024; // 100 MB - let mut total_decompressed: usize = 0; - - for i in 0..archive.len() { - let mut entry = archive - .by_index(i) - .map_err(|e| format!("Failed to read ZIP entry: {e}"))?; - let safe_name = match entry.enclosed_name() { - Some(name) => name.to_path_buf(), - None => continue, - }; - let out_path = tmp.path().join(&safe_name); - if entry.is_dir() { - std::fs::create_dir_all(&out_path).map_err(|e| format!("Failed to create dir: {e}"))?; - } else { - if let Some(parent) = out_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create parent dir: {e}"))?; - } - let mut data = Vec::new(); - std::io::Read::read_to_end(&mut entry, &mut data) - .map_err(|e| format!("Read error: {e}"))?; - total_decompressed += data.len(); - if total_decompressed > max_decompressed { - return Err("ZIP decompressed content exceeds 100MB limit".to_string()); - } - std::fs::write(&out_path, &data).map_err(|e| format!("Write error: {e}"))?; - } - } - - let pack_root = crate::managed_agents::find_plugin_json(tmp.path()) - .ok_or_else(|| "No .plugin/plugin.json found in ZIP".to_string())?; - let resolved = buzz_persona_pkg::resolve::resolve_pack(&pack_root) - .map_err(|e| format!("Pack validation failed: {e}"))?; - - if resolved.personas.is_empty() { - return Err("Pack contains no personas.".to_string()); - } - - Ok(ParsedTeamPreview { - name: resolved.name, - description: if resolved.description.is_empty() { - None - } else { - Some(resolved.description) - }, - personas: resolved - .personas - .into_iter() - .map(|p| TeamPersonaPreview { - display_name: p.display_name, - system_prompt: p.system_prompt, - avatar_url: None, - }) - .collect(), - }) -} diff --git a/desktop/src-tauri/src/commands/window_vibrancy.rs b/desktop/src-tauri/src/commands/window_vibrancy.rs new file mode 100644 index 0000000000..5eb1f16b8b --- /dev/null +++ b/desktop/src-tauri/src/commands/window_vibrancy.rs @@ -0,0 +1,69 @@ +//! Runtime macOS window vibrancy (blur-behind) toggle. +//! +//! Vibrancy applies an `NSVisualEffectView` behind the webview so the desktop +//! (and windows behind Buzz) blur through wherever the app's CSS is +//! transparent. It is a native, macOS-only effect: there is no "intensity" +//! setting at the OS level, only a set of material presets. The frontend tunes +//! perceived intensity by changing CSS surface opacity while this command +//! handles the native material. +//! +//! This is fully reversible at runtime: enabling applies the chosen material, +//! disabling clears it. On non-macOS platforms the command is a no-op so the +//! shared frontend can call it unconditionally. + +#[cfg(target_os = "macos")] +use tauri::Manager; + +/// Apply or clear macOS window vibrancy for the main window. +/// +/// `material` accepts the common `NSVisualEffectMaterial` names +/// (`sidebar`, `hud-window`, `under-window-background`, `fullscreen-ui`, +/// `header-view`, `popover`, `menu`, `titlebar`). Unknown values fall back to +/// `sidebar`. +#[tauri::command] +pub fn set_window_vibrancy( + #[allow(unused_variables)] enabled: bool, + #[allow(unused_variables)] material: Option, + #[allow(unused_variables)] app_handle: tauri::AppHandle, +) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + use window_vibrancy::{apply_vibrancy, clear_vibrancy, NSVisualEffectMaterial}; + + let window = app_handle + .get_webview_window("main") + .ok_or_else(|| "main window not found".to_string())?; + + if !enabled { + clear_vibrancy(&window).map_err(|e| e.to_string())?; + return Ok(()); + } + + let material = match material.as_deref() { + Some("hud-window") => NSVisualEffectMaterial::HudWindow, + Some("under-window-background") => NSVisualEffectMaterial::UnderWindowBackground, + Some("fullscreen-ui") => NSVisualEffectMaterial::FullScreenUI, + Some("header-view") => NSVisualEffectMaterial::HeaderView, + Some("popover") => NSVisualEffectMaterial::Popover, + Some("menu") => NSVisualEffectMaterial::Menu, + Some("titlebar") => NSVisualEffectMaterial::Titlebar, + _ => NSVisualEffectMaterial::Sidebar, + }; + + // `apply_vibrancy` appends a new tagged `NSVisualEffectView` each call, + // while `clear_vibrancy` only removes one. Repeated enables (theme + // switches, follow-system flips) would otherwise stack blur views and + // leave a stale one behind on the next non-Buzz theme. Clear any + // existing view first so exactly one material is ever installed. The + // clear is a no-op (returns `false`) when none is present. + let _ = clear_vibrancy(&window); + + apply_vibrancy(&window, material, None, None).map_err(|e| e.to_string())?; + Ok(()) + } + + #[cfg(not(target_os = "macos"))] + { + Ok(()) + } +} diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index 6dfeb6b518..1d5f309fb5 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -66,6 +66,36 @@ pub async fn get_channel_workflows( Ok(events.iter().map(workflow_from_event).collect()) } +/// Fetch workflows across many channels in a single relay round-trip. +/// +/// The Workflows overview screen previously issued one `get_channel_workflows` +/// query per member channel (`Promise.all` fanout in `WorkflowsView`), i.e. N +/// relay POSTs. A nostr `#h` filter matches ANY of its listed values, so one +/// query with all channel ids returns the same set. Each `WorkflowWire` carries +/// its own `channel_id` (from the event's `h` tag), so the frontend can still +/// group results by channel. Neither this nor the per-channel command sets a +/// `limit`, so batching does not change result completeness. +#[tauri::command] +pub async fn get_channels_workflows( + channel_ids: Vec, + state: State<'_, AppState>, +) -> Result, String> { + if channel_ids.is_empty() { + return Ok(Vec::new()); + } + + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [30620], + "#h": channel_ids, + })], + ) + .await?; + + Ok(events.iter().map(workflow_from_event).collect()) +} + #[tauri::command] pub async fn get_workflow( workflow_id: String, diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index e0ab14a439..c602bdce42 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -1,11 +1,12 @@ use nostr::Keys; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter, State}; +use std::sync::atomic::Ordering; +use tauri::{AppHandle, Emitter, Manager, State}; use crate::app_state::AppState; use crate::managed_agents::{ - effective_repos_dir, ensure_repos_symlink, nest_dir, try_regenerate_nest, - write_persisted_repos_dir, + effective_repos_dir, ensure_repos_symlink, nest_dir, restore_managed_agents_on_launch, + try_regenerate_nest, write_persisted_repos_dir, }; use crate::relay; @@ -71,13 +72,17 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result Result<(), String> { - let trimmed = dir.trim(); - if trimmed.is_empty() { - return Ok(()); - } - let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; - crate::managed_agents::validate_repos_dir(&nest, trimmed).map(|_| ()) +pub async fn validate_repos_dir(dir: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let trimmed = dir.trim(); + if trimmed.is_empty() { + return Ok(()); + } + let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; + crate::managed_agents::validate_repos_dir(&nest, trimmed).map(|_| ()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } /// Apply a workspace's configuration to the backend session. @@ -94,73 +99,102 @@ pub fn validate_repos_dir(dir: String) -> Result<(), String> { /// already block a bad path at Save (`validate_repos_dir`); this fallback only /// catches a value that went bad after save (deleted dir, unmounted volume). #[tauri::command] -pub fn apply_workspace( +pub async fn apply_workspace( relay_url: String, nsec: Option, repos_dir: Option, app: AppHandle, - state: State<'_, AppState>, ) -> Result<(), String> { - // ── Validate before mutating ────────────────────────────────────────── - let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) { - Some(nsec_trimmed) => { - Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?) - } - None => None, - }; + let restore_app = app.clone(); + tokio::task::spawn_blocking(move || { + let state = app.state::(); - // Decide the effective repos_dir from the candidate. A bad path does NOT - // reject — it is treated as if no override were set: relay/keys still - // apply, the bad value is not persisted, and a `repos-dir-error` surfaces - // the reason. Persisting a bad path would make every later boot read it, - // fail to resolve the symlink, and silently skip agent restore. One - // validate (inside `effective_repos_dir`) drives both the emit and the - // persisted value. `nest` is resolved softly: when absent there is nothing - // to persist or symlink, and relay/keys must still apply unconditionally. - let nest = nest_dir(); - let effective_repos_dir = match nest.as_deref() { - Some(nest) => match effective_repos_dir(nest, repos_dir.as_deref()) { - Ok(value) => value, - Err(error) => { - let _ = app.emit("repos-dir-error", error); - None + // ── Validate before mutating ────────────────────────────────────────── + let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(nsec_trimmed) => { + Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?) } - }, - None => None, - }; + None => None, + }; - // ── Apply all state changes (nothing below can fail) ────────────────── + // Decide the effective repos_dir from the candidate. A bad path does NOT + // reject — it is treated as if no override were set: relay/keys still + // apply, the bad value is not persisted, and a `repos-dir-error` surfaces + // the reason. Persisting a bad path would make every later boot read it, + // fail to resolve the symlink, and silently skip agent restore. One + // validate (inside `effective_repos_dir`) drives both the emit and the + // persisted value. `nest` is resolved softly: when absent there is nothing + // to persist or symlink, and relay/keys must still apply unconditionally. + let nest = nest_dir(); + let effective_repos_dir = match nest.as_deref() { + Some(nest) => match effective_repos_dir(nest, repos_dir.as_deref()) { + Ok(value) => value, + Err(error) => { + let _ = app.emit("repos-dir-error", error); + None + } + }, + None => None, + }; + + // ── Apply all state changes (nothing below can fail) ────────────────── + { + let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; + *override_guard = Some(relay_url); + } + + if let Some(keys) = parsed_keys { + let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?; + *keys_guard = keys; + } + + // ── Filesystem side-effect (non-fatal) ──────────────────────────────── + // Persist the *effective* repos_dir (None when the candidate failed + // validation) for the backend to read at boot, then re-point REPOS to + // match. Persisting first makes the dotfile authoritative even if the + // symlink apply fails here (e.g. a non-empty real REPOS): the next boot + // reads the persisted value and resolves the symlink before any agent can + // clone into REPOS. A bad candidate persists `None`, so the next boot is + // clean and agent restore proceeds. Failure of either must NOT fail the + // command — relay/keys are already applied. Surface symlink errors via + // `repos-dir-error`. + if let Some(nest) = nest.as_deref() { + if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) { + eprintln!("buzz-desktop: persist repos dir failed: {error}"); + } + if let Err(error) = ensure_repos_symlink(nest, effective_repos_dir.as_deref()) { + eprintln!("buzz-desktop: repos dir setup failed: {error}"); + let _ = app.emit("repos-dir-error", error); + } + } + + try_regenerate_nest(&app); + + Ok::<(), String>(()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + let state = restore_app.state::(); + if state + .managed_agent_restore_pending + .swap(false, Ordering::AcqRel) { - let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; - *override_guard = Some(relay_url); + let app = restore_app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + #[cfg(feature = "mesh-llm")] + if let Err(error) = crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await + { + eprintln!("buzz-desktop: failed to restore Share Compute: {error}"); + } + if let Err(error) = + restore_managed_agents_on_launch(&app, &state.shutdown_started).await + { + eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + } + }); } - if let Some(keys) = parsed_keys { - let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?; - *keys_guard = keys; - } - - // ── Filesystem side-effect (non-fatal) ──────────────────────────────── - // Persist the *effective* repos_dir (None when the candidate failed - // validation) for the backend to read at boot, then re-point REPOS to - // match. Persisting first makes the dotfile authoritative even if the - // symlink apply fails here (e.g. a non-empty real REPOS): the next boot - // reads the persisted value and resolves the symlink before any agent can - // clone into REPOS. A bad candidate persists `None`, so the next boot is - // clean and agent restore proceeds. Failure of either must NOT fail the - // command — relay/keys are already applied. Surface symlink errors via - // `repos-dir-error`. - if let Some(nest) = nest.as_deref() { - if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) { - eprintln!("buzz-desktop: persist repos dir failed: {error}"); - } - if let Err(error) = ensure_repos_symlink(nest, effective_repos_dir.as_deref()) { - eprintln!("buzz-desktop: repos dir setup failed: {error}"); - let _ = app.emit("repos-dir-error", error); - } - } - - try_regenerate_nest(&app); - Ok(()) } diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 2940cfeec3..f90d2f390a 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -1,6 +1,25 @@ -use tauri::Emitter; +use serde::Serialize; +use tauri::{Emitter, Manager}; use url::Url; +use crate::nostr_bind; + +fn activate_main_window(app: &tauri::AppHandle) { + let Some(window) = app.get_webview_window("main") else { + return; + }; + + if let Err(error) = window.unminimize() { + eprintln!("buzz-desktop: failed to unminimize main window for deep link: {error}"); + } + if let Err(error) = window.show() { + eprintln!("buzz-desktop: failed to show main window for deep link: {error}"); + } + if let Err(error) = window.set_focus() { + eprintln!("buzz-desktop: failed to focus main window for deep link: {error}"); + } +} + /// Parse the query string of a `buzz://message?…` URL into the JSON /// payload emitted on `deep-link-message`. Returns `None` when a required /// param (`channel`, `id`) is missing or empty — mirroring the validation @@ -33,6 +52,131 @@ fn parse_message_deep_link(url: &Url) -> Option { })) } +/// Parse the query string of a `buzz://join?…` URL into the JSON payload +/// emitted on `deep-link-join`. Requires a ws(s) `relay` URL and a non-empty +/// `code`; returns `None` otherwise so the frontend never sees a half-formed +/// payload. +fn parse_join_deep_link(url: &Url) -> Option { + let mut relay: Option = None; + let mut code: Option = None; + for (k, v) in url.query_pairs() { + let v = v.into_owned(); + if v.is_empty() { + continue; + } + match k.as_ref() { + "relay" => relay = Some(v), + "code" => code = Some(v), + _ => {} + } + } + let (relay_url, code) = (relay?, code?); + match Url::parse(&relay_url) { + Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {} + _ => return None, + } + Some(serde_json::json!({ + "relayUrl": relay_url, + "code": code, + })) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct NostrBindDeepLinkPayload { + challenge_id: String, + nonce: String, + verification_code: String, + audience: String, + action: String, + protocol: String, + version: String, + origin: String, + expires_at: String, + return_mode: String, + callback_url: Option, +} + +fn non_empty_param(url: &Url, name: &str) -> Result { + url.query_pairs() + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("missing {name}")) +} + +fn optional_non_empty_param(url: &Url, name: &str) -> Option { + url.query_pairs() + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) + .filter(|value| !value.is_empty()) +} + +fn validate_nostr_bind_callback_url(callback_url: &str, origin: &str) -> Result<(), String> { + let callback = + Url::parse(callback_url).map_err(|error| format!("invalid callback_url: {error}"))?; + let origin = Url::parse(origin).map_err(|error| format!("invalid origin: {error}"))?; + if callback.scheme() != "https" { + return Err("callback_url must use https".into()); + } + if callback.host_str().is_none() { + return Err("callback_url missing host".into()); + } + if !callback.username().is_empty() || callback.password().is_some() { + return Err("callback_url must not include credentials".into()); + } + if callback.scheme() != origin.scheme() + || callback.host_str() != origin.host_str() + || callback.port_or_known_default() != origin.port_or_known_default() + { + return Err("callback_url must match origin".into()); + } + Ok(()) +} + +fn parse_nostr_bind_deep_link(url: &Url) -> Result { + let challenge_id = non_empty_param(url, "challenge_id")?; + let nonce = non_empty_param(url, "nonce")?; + let verification_code = non_empty_param(url, "verification_code")?; + let audience = non_empty_param(url, "audience")?; + let action = non_empty_param(url, "action")?; + let protocol = non_empty_param(url, "protocol")?; + let version = non_empty_param(url, "version")?; + let origin = non_empty_param(url, "origin")?; + let expires_at = non_empty_param(url, "expires_at")?; + let return_mode = non_empty_param(url, "return")?; + let callback_url = optional_non_empty_param(url, "callback_url"); + + nostr_bind::validate_challenge_id(&challenge_id)?; + nostr_bind::validate_nonce(&nonce)?; + nostr_bind::validate_verification_code(&verification_code)?; + nostr_bind::validate_protocol_fields(&audience, &action, &protocol, &version)?; + nostr_bind::validate_origin(&origin)?; + // Expired links still reach the consent surface so the user gets an explicit + // failure instead of a silent stderr-only rejection from a launched app. + nostr_bind::validate_expires_at_format(&expires_at)?; + if return_mode != nostr_bind::RETURN_MODE { + return Err("unsupported return mode".into()); + } + if let Some(callback_url) = callback_url.as_deref() { + validate_nostr_bind_callback_url(callback_url, &origin)?; + } + + Ok(NostrBindDeepLinkPayload { + challenge_id, + nonce, + verification_code, + audience, + action, + protocol, + version, + origin, + expires_at, + return_mode, + callback_url, + }) +} + /// Handle an incoming `buzz://` deep link URL. /// /// Currently supports: @@ -76,8 +220,20 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { return; } } + activate_main_window(app); let _ = app.emit("deep-link-connect", relay_url); } + Some("join") => { + // `buzz://join?relay=&code=` — fired by + // the relay's /invite/ landing page. The frontend claims the + // invite against the relay's HTTP API, then adds the workspace. + let Some(payload) = parse_join_deep_link(&url) else { + eprintln!("buzz-desktop: join deep link missing/invalid relay or code: {url_str}"); + return; + }; + activate_main_window(app); + let _ = app.emit("deep-link-join", payload); + } Some("message") => { // `buzz://message?channel=&id=[&thread=]` // @@ -91,8 +247,18 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { eprintln!("buzz-desktop: message deep link missing channel or id: {url_str}"); return; }; + activate_main_window(app); let _ = app.emit("deep-link-message", payload); } + Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { + Ok(payload) => { + activate_main_window(app); + let _ = app.emit("deep-link-nostr-bind", payload); + } + Err(error) => { + eprintln!("buzz-desktop: rejecting nostr-bind deep link: {error}: {url_str}"); + } + }, Some(action) => { eprintln!("buzz-desktop: unknown deep link action: {action}"); } @@ -106,7 +272,14 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { mod tests { use url::Url; - use super::parse_message_deep_link; + use super::{parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link}; + + fn valid_nostr_bind_url() -> Url { + Url::parse( + "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", + ) + .unwrap() + } #[test] fn parse_message_deep_link_extracts_required_params() { @@ -157,4 +330,153 @@ mod tests { let payload = parse_message_deep_link(&url).expect("required params present"); assert!(payload["threadRootId"].is_null()); } + + #[test] + fn parse_join_deep_link_extracts_relay_and_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["relayUrl"], "wss://relay.example"); + assert_eq!(payload["code"], "abc.def"); + } + + #[test] + fn parse_join_deep_link_rejects_missing_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); + } + + #[test] + fn parse_join_deep_link_rejects_empty_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); + } + + #[test] + fn parse_join_deep_link_rejects_missing_relay() { + let url = Url::parse("buzz://join?code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); + } + + #[test] + fn parse_join_deep_link_rejects_non_websocket_relay() { + let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); + } + + #[test] + fn parse_nostr_bind_deep_link_accepts_valid_url() { + let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); + assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); + assert_eq!(payload.verification_code, "123456"); + assert_eq!(payload.audience, "buzz:nostr-identity"); + assert_eq!(payload.action, "bind_nostr_identity"); + assert_eq!(payload.protocol, "buzz-nostr-identity"); + assert_eq!(payload.version, "1"); + assert_eq!(payload.origin, "https://example.com"); + assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); + assert_eq!(payload.return_mode, "clipboard"); + assert_eq!(payload.callback_url, None); + } + + #[test] + fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz?mockSession=1") + ); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_http_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { + let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_empty_nonce() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_short_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_long_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_wrong_action() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_wrong_audience() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_non_https_origin() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_origin_with_path() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); + } } diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs new file mode 100644 index 0000000000..2ec5aa1c0e --- /dev/null +++ b/desktop/src-tauri/src/event_sync.rs @@ -0,0 +1,316 @@ +//! Boot-time disk→relay event reconcile ("event sync"). +//! +//! Reconciles the on-disk JSON stores (`personas.json`, `teams.json`, +//! `managed-agents.json`) into signed retention events queued for relay +//! publish. Runs after identity resolution (event signing needs the owner +//! keys), unlike the pre-identity migrations in [`crate::migration`]. + +use std::path::Path; + +/// Reconcile personas, teams, and managed agents into signed retention +/// events. All readers consume the already-synced +/// `personas.json`/`teams.json`/`managed-agents.json` that +/// `sync_team_personas` wrote in [`crate::migration::run_boot_migrations`] +/// (see its `# Ordering` guard). Event signing needs the resolved owner keys, +/// so this runs after identity resolution, not in the boot migrations. +pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) { + migrate_personas_to_events(app, owner_keys); + migrate_teams_to_events(app, owner_keys); + crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys); +} + +/// Spawn the best-effort event reconcile off the synchronous Tauri setup path. +/// +/// The owner keys are cloned before spawning so the task never touches the +/// `AppState::keys` mutex. The reconcile itself is still synchronous JSON, +/// SQLite, and signing work, so it runs on the blocking pool rather than an +/// async worker. +pub fn spawn_event_sync(app: tauri::AppHandle, owner_keys: nostr::Keys) { + tauri::async_runtime::spawn(async move { + if let Err(e) = tauri::async_runtime::spawn_blocking(move || { + run_event_sync(&app, &owner_keys); + }) + .await + { + eprintln!("buzz-desktop: event-sync: spawn_blocking failed: {e}"); + } + }); +} + +/// Reconcile `personas.json` into the persona-event retention store. +/// +/// Must run AFTER `fold_personas_into_agent_store` and +/// `detach_directory_backed_teams` (depends on field renames and store +/// unification being complete) and AFTER the persisted identity is resolved +/// (it signs every retained event with the owner's keys). +/// +/// Per-record reconcile: for each non-builtin persona it compares the freshly +/// serialized event content against the retained row at the same coordinate +/// and re-retains (marking `pending_sync = 1`) only when the row is absent or +/// its content differs. An unchanged persona is left untouched, so a launch +/// after a no-op edit does not churn `pending_sync`; a persona added or edited +/// on disk between launches is picked up and republished. There is no +/// whole-store sentinel — comparing per coordinate is what lets newly added +/// personas reach the relay. +/// +/// Strategy: write to local SQLite retention first (durable copy), mark as +/// `pending_sync = 1` for later relay publish. Migration succeeds on local +/// write, not relay acknowledgment. Every retained row is a real signed +/// event — there is no placeholder path. +pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { + use crate::managed_agents::managed_agents_base_dir; + + let Ok(base_dir) = managed_agents_base_dir(app) else { + return; + }; + + match migrate_personas_in_dir(&base_dir, keys) { + Ok(0) => {} + Ok(migrated) => { + eprintln!( + "buzz-desktop: persona-event-migration: {migrated} personas migrated to retention" + ); + } + Err(e) => { + eprintln!("buzz-desktop: persona-event-migration: {e}"); + } + } +} + +/// Core reconcile logic, decoupled from the Tauri `AppHandle` for testing. +/// +/// Returns the number of personas (re)written to the retention store. Returns +/// `Ok(0)` when every non-builtin persona already has a matching retained row +/// (or there are none to reconcile). +fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + use crate::managed_agents::{ + persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + AgentDefinition, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + use nostr::JsonUtil; + + let pubkey = keys.public_key().to_hex(); + + // Post-fold (Phase 1A.2): definitions live as key-less records in the + // unified agent store, presented in the legacy shape. Pre-fold boots + // (run_event_sync runs after run_boot_migrations, so the fold has + // already happened) never reach this path with personas.json present — + // but read it as a fallback for one release in case the fold errored. + let records: Vec = { + let personas_path = base_dir.join("personas.json"); + if personas_path.exists() { + let content = std::fs::read_to_string(&personas_path) + .map_err(|e| format!("failed to read personas.json: {e}"))?; + serde_json::from_str(&content) + .map_err(|e| format!("failed to parse personas.json: {e}"))? + } else { + let agents_path = base_dir.join("managed-agents.json"); + if !agents_path.exists() { + return Ok(0); + } + let content = std::fs::read_to_string(&agents_path) + .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; + let all: Vec = + serde_json::from_str(&content) + .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; + all.iter() + .filter(|record| record.pubkey.is_empty()) + .filter_map(|record| record.to_definition_view()) + .collect() + } + }; + + if records.is_empty() { + return Ok(0); + } + + // Open (or create) the retention database. + let db_path = base_dir.join("retention.db"); + let conn = + open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; + + let mut migrated = 0u32; + + for record in &records { + // Skip built-in personas — they're always available from code. + if record.is_builtin { + continue; + } + + let d_tag = persona_d_tag(record); + + // Fetch the retained head first so the rebuilt event can supersede it: + // build at the default `now` and a future-dated head (clock skew, or an + // interactive same-second `max(now, head+1)` bump) would make + // `retain_event`'s `created_at >= ...` guard SILENTLY skip the UPDATE + // while `migrated` over-reports. Mirror the interactive sites' monotonic + // bump (F1) so a changed body always lands. + let existing = get_retained_event(&conn, KIND_PERSONA, &pubkey, &d_tag)?; + + let event = build_persona_event(record) + .map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))? + .custom_created_at(monotonic_created_at( + existing.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event for '{}': {e}", record.display_name))?; + + // Per-coordinate reconcile: skip when an identical body is already + // retained, so an unchanged persona doesn't reset `pending_sync`. + // Content is timestamp-independent, so the monotonic bump above never + // forces a spurious republish. + let event_content = event.content.to_string(); + if existing + .as_ref() + .is_some_and(|row| row.content == event_content) + { + continue; + } + + let retained = RetainedEvent { + kind: KIND_PERSONA, + pubkey: pubkey.clone(), + d_tag, + content: event_content, + // Safety: nostr timestamps are seconds and stay below i64::MAX + // until year 2262. + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }; + + // The monotonic bump guarantees `created_at > head`, so the upsert's + // `>=` guard always lands the UPDATE — `migrated` counts only real, + // retained republishes. + retain_event(&conn, &retained) + .map_err(|e| format!("failed to retain '{}': {e}", record.display_name))?; + migrated += 1; + } + + Ok(migrated) +} + +/// Reconcile `teams.json` into kind:30176 team events in the retention store. +/// +/// Mirrors [`migrate_personas_to_events`] for teams: it picks up team metadata +/// edits (name/description/persona_ids) made on disk between launches and +/// queues them for relay publish. Managed agents (kind:30177) are deliberately +/// NOT reconciled here — they have no pack/dir source and are backfilled from +/// `managed-agents.json` elsewhere. +/// +/// Must run after the persisted identity is resolved (it signs each event with +/// the owner's keys). +pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { + use crate::managed_agents::managed_agents_base_dir; + + let Ok(base_dir) = managed_agents_base_dir(app) else { + return; + }; + + match migrate_teams_in_dir(&base_dir, keys) { + Ok(0) => {} + Ok(migrated) => { + eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention"); + } + Err(e) => { + eprintln!("buzz-desktop: team-event-migration: {e}"); + } + } +} + +/// Core team reconcile logic, decoupled from the Tauri `AppHandle` for testing. +/// +/// Returns the number of teams (re)written to the retention store. The +/// per-coordinate content compare matches [`migrate_personas_in_dir`]: an +/// unchanged team is skipped so a launch does not churn `pending_sync`. +fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + use crate::managed_agents::{ + persona_events::monotonic_created_at, + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + team_events::build_team_event, + TeamRecord, + }; + use buzz_core_pkg::kind::KIND_TEAM; + use nostr::JsonUtil; + + let pubkey = keys.public_key().to_hex(); + + let teams_path = base_dir.join("teams.json"); + if !teams_path.exists() { + return Ok(0); + } + + let content = std::fs::read_to_string(&teams_path) + .map_err(|e| format!("failed to read teams.json: {e}"))?; + + let records: Vec = + serde_json::from_str(&content).map_err(|e| format!("failed to parse teams.json: {e}"))?; + + if records.is_empty() { + return Ok(0); + } + + let db_path = base_dir.join("retention.db"); + let conn = + open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; + + let mut migrated = 0u32; + + for record in &records { + // Skip built-in teams — they're always available from code. + if record.is_builtin { + continue; + } + + // Team d-tag is the team id (team_events.rs: no slug fallback). + let d_tag = record.id.clone(); + + // Fetch the head first so the monotonic bump can supersede a + // future-dated head — see migrate_personas_in_dir (F1/F8). + let existing = get_retained_event(&conn, KIND_TEAM, &pubkey, &d_tag)?; + + let event = build_team_event(record) + .map_err(|e| format!("failed to build event for team '{}': {e}", record.name))? + .custom_created_at(monotonic_created_at( + existing.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event for team '{}': {e}", record.name))?; + + let event_content = event.content.to_string(); + if existing + .as_ref() + .is_some_and(|row| row.content == event_content) + { + continue; + } + + let retained = RetainedEvent { + kind: KIND_TEAM, + pubkey: pubkey.clone(), + d_tag, + content: event_content, + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }; + + // Monotonic bump guarantees the upsert UPDATE lands — `migrated` counts + // only real republishes. + retain_event(&conn, &retained) + .map_err(|e| format!("failed to retain team '{}': {e}", record.name))?; + migrated += 1; + } + + Ok(migrated) +} + +#[cfg(test)] +#[path = "event_sync_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "event_sync_team_events_tests.rs"] +mod team_events_tests; diff --git a/desktop/src-tauri/src/migration_team_events_tests.rs b/desktop/src-tauri/src/event_sync_team_events_tests.rs similarity index 100% rename from desktop/src-tauri/src/migration_team_events_tests.rs rename to desktop/src-tauri/src/event_sync_team_events_tests.rs diff --git a/desktop/src-tauri/src/event_sync_tests.rs b/desktop/src-tauri/src/event_sync_tests.rs new file mode 100644 index 0000000000..f7e0d88d13 --- /dev/null +++ b/desktop/src-tauri/src/event_sync_tests.rs @@ -0,0 +1,301 @@ +use super::*; + +/// Helper: write a `personas.json` directly in `base_dir` (the migration +/// reads `base_dir/personas.json`, where `base_dir` is the `agents` dir). +fn write_base_personas(base_dir: &Path, records: &serde_json::Value) { + std::fs::write( + base_dir.join("personas.json"), + serde_json::to_string_pretty(records).unwrap(), + ) + .unwrap(); +} + +fn one_persona() -> serde_json::Value { + serde_json::json!([{ + "id": "code-reviewer", + "display_name": "Code Reviewer", + "system_prompt": "You review code.", + "is_builtin": false, + "is_active": true, + "name_pool": [], + "env_vars": {}, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }]) +} + +#[test] +fn migrate_personas_writes_signed_retention_rows() { + use crate::managed_agents::retention::{get_retained_personas, open_retention_db}; + + let base = tempfile::tempdir().unwrap(); + write_base_personas(base.path(), &one_persona()); + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + + let migrated = migrate_personas_in_dir(base.path(), &keys).unwrap(); + assert_eq!(migrated, 1); + + let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); + let rows = get_retained_personas(&conn, &pubkey).unwrap(); + assert_eq!(rows.len(), 1); + // Row holds a real signed event for the owner — not a placeholder. + assert_eq!(rows[0].pubkey, pubkey); + let event: nostr::Event = nostr::JsonUtil::from_json(&rows[0].raw_event).unwrap(); + assert!(event.verify().is_ok()); + assert!(rows[0].pending_sync); +} + +#[test] +fn migrate_personas_skips_builtins() { + use crate::managed_agents::retention::{get_retained_personas, open_retention_db}; + + let base = tempfile::tempdir().unwrap(); + write_base_personas( + base.path(), + &serde_json::json!([{ + "id": "builtin:solo", + "display_name": "Solo", + "system_prompt": "x", + "is_builtin": true, + "is_active": true, + "name_pool": [], + "env_vars": {}, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }]), + ); + let keys = nostr::Keys::generate(); + + let migrated = migrate_personas_in_dir(base.path(), &keys).unwrap(); + assert_eq!(migrated, 0); + + let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); + let rows = get_retained_personas(&conn, &keys.public_key().to_hex()).unwrap(); + assert!(rows.is_empty()); +} + +#[test] +fn migrate_personas_unchanged_second_run_is_noop() { + let base = tempfile::tempdir().unwrap(); + write_base_personas(base.path(), &one_persona()); + let keys = nostr::Keys::generate(); + + // First run retains; second run with identical personas re-retains + // nothing — the per-coordinate content matches, so `pending_sync` is + // not churned. + assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); + assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 0); + assert!(!base.path().join("migration_state.json").exists()); +} + +#[test] +fn migrate_personas_new_persona_after_first_run_gets_retained() { + use crate::managed_agents::retention::{get_retained_personas, open_retention_db}; + + let base = tempfile::tempdir().unwrap(); + write_base_personas(base.path(), &one_persona()); + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + + assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); + + // A persona added to personas.json after the first reconcile must be + // picked up — the whole-store sentinel that previously short-circuited + // this is gone. + let mut two = one_persona(); + two.as_array_mut().unwrap().push(serde_json::json!({ + "id": "test-writer", + "display_name": "Test Writer", + "system_prompt": "You write tests.", + "is_builtin": false, + "is_active": true, + "name_pool": [], + "env_vars": {}, + "created_at": "2025-01-02T00:00:00Z", + "updated_at": "2025-01-02T00:00:00Z" + })); + write_base_personas(base.path(), &two); + + assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); + + let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); + let rows = get_retained_personas(&conn, &pubkey).unwrap(); + assert_eq!(rows.len(), 2); +} + +#[test] +fn migrate_personas_edited_persona_re_retains_pending() { + use crate::managed_agents::retention::{get_retained_event, mark_synced, open_retention_db}; + use buzz_core_pkg::kind::KIND_PERSONA; + + let base = tempfile::tempdir().unwrap(); + write_base_personas(base.path(), &one_persona()); + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + + assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); + + // Simulate the flush loop confirming the first publish. + let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); + let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer") + .unwrap() + .unwrap(); + mark_synced( + &conn, + KIND_PERSONA, + &pubkey, + "code-reviewer", + row.created_at, + &row.content, + ) + .unwrap(); + drop(conn); + + // Editing the persona on disk must re-retain it as pending so the edit + // reaches the relay on the next flush. + let mut edited = one_persona(); + edited.as_array_mut().unwrap()[0]["system_prompt"] = + serde_json::json!("You review code carefully."); + write_base_personas(base.path(), &edited); + + assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); + + let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); + let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer") + .unwrap() + .unwrap(); + assert!(row.pending_sync); + assert!(row.content.contains("carefully")); +} + +#[test] +fn migrate_personas_no_file_is_noop() { + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 0); +} + +/// F8: a future-dated retained head must be SUPERSEDED on a changed-content +/// migration, not silently skipped by `retain_event`'s `>=` guard. Without the +/// monotonic `created_at` bump the rebuilt event lands at `now <= head`, the +/// upsert's `WHERE excluded.created_at >= ...` drops the UPDATE, and `migrated` +/// over-reports. The bump (max(now, head+1)) guarantees supersession. +#[test] +fn migrate_personas_supersedes_future_dated_head() { + use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, retain_event, RetainedEvent, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + + let base = tempfile::tempdir().unwrap(); + write_base_personas(base.path(), &one_persona()); + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + + // First migrate retains the persona at ~now. + assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); + + // Force the retained head far into the future, simulating a clock-skewed or + // same-second `max(now, head+1)` interactive bump. + let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); + let head = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer") + .unwrap() + .unwrap(); + let future = nostr::Timestamp::now().as_secs() as i64 + 100_000; + retain_event( + &conn, + &RetainedEvent { + created_at: future, + pending_sync: false, + ..head + }, + ) + .unwrap(); + + // Change the persona body on disk, then migrate again. + let mut edited = one_persona(); + edited.as_array_mut().unwrap()[0]["system_prompt"] = + serde_json::json!("You review code very carefully."); + write_base_personas(base.path(), &edited); + + assert_eq!( + migrate_personas_in_dir(base.path(), &keys).unwrap(), + 1, + "changed content over a future-dated head must report a real migration" + ); + + let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer") + .unwrap() + .unwrap(); + // The new body actually landed (not silently skipped) ... + assert!( + row.content.contains("very carefully"), + "changed body must supersede the future-dated head, not be dropped" + ); + // ... at a created_at strictly past the future head (monotonic bump) ... + assert_eq!(row.created_at, future + 1); + // ... and is queued for republish. + assert!(row.pending_sync, "superseding row must be pending_sync"); +} + +fn write_base_teams(base_dir: &Path, records: &serde_json::Value) { + std::fs::write( + base_dir.join("teams.json"), + serde_json::to_string_pretty(records).unwrap(), + ) + .unwrap(); +} + +/// F8 for the team migration site — same supersede guarantee as personas. +#[test] +fn migrate_teams_supersedes_future_dated_head() { + use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, retain_event, RetainedEvent, + }; + use buzz_core_pkg::kind::KIND_TEAM; + + let base = tempfile::tempdir().unwrap(); + let team = serde_json::json!([{ + "id": "my-team", + "name": "My Team", + "description": "first", + "persona_ids": ["code-reviewer"], + "is_builtin": false, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }]); + write_base_teams(base.path(), &team); + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + + assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1); + + let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); + let head = get_retained_event(&conn, KIND_TEAM, &pubkey, "my-team") + .unwrap() + .unwrap(); + let future = nostr::Timestamp::now().as_secs() as i64 + 100_000; + retain_event( + &conn, + &RetainedEvent { + created_at: future, + pending_sync: false, + ..head + }, + ) + .unwrap(); + + let mut edited = team.clone(); + edited.as_array_mut().unwrap()[0]["description"] = serde_json::json!("second"); + write_base_teams(base.path(), &edited); + + assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1); + + let row = get_retained_event(&conn, KIND_TEAM, &pubkey, "my-team") + .unwrap() + .unwrap(); + assert!(row.content.contains("second")); + assert_eq!(row.created_at, future + 1); + assert!(row.pending_sync); +} diff --git a/desktop/src-tauri/src/huddle/audio_output.rs b/desktop/src-tauri/src/huddle/audio_output.rs index 02acf27305..dbd09353db 100644 --- a/desktop/src-tauri/src/huddle/audio_output.rs +++ b/desktop/src-tauri/src/huddle/audio_output.rs @@ -6,7 +6,13 @@ use crate::app_state::AppState; /// List available audio output devices. Returns (name, is_default) pairs. #[tauri::command] -pub fn list_audio_output_devices() -> Result, String> { +pub async fn list_audio_output_devices() -> Result, String> { + tokio::task::spawn_blocking(list_audio_output_devices_blocking) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +fn list_audio_output_devices_blocking() -> Result, String> { use rodio::cpal::traits::HostTrait; use rodio::DeviceTrait; diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 5499ccab4c..8a8f1ec9ff 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -31,6 +31,7 @@ pub mod pipeline; pub mod playout; pub mod pocket; pub mod preprocessing; +pub mod reconnect; pub mod relay_api; pub mod state; pub mod stt; diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index ebae8e3397..bc9a1abd41 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -24,14 +24,18 @@ pub(crate) async fn post_connect_setup( state: &AppState, ephemeral_channel_id: &str, ) -> Result<(), String> { - // Hydrate agent pubkeys from relay (authoritative — overrides local guess). - if let Ok(agents) = fetch_channel_members(ephemeral_channel_id, Some("bot"), state).await { + // Hydrate agent pubkeys and participants from relay in parallel + // (authoritative — overrides local guesses). + let (agents_result, all_members_result) = tokio::join!( + fetch_channel_members(ephemeral_channel_id, Some("bot"), state), + fetch_channel_members(ephemeral_channel_id, None, state), + ); + if let Ok(agents) = agents_result { let hs = state.huddle()?; *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; } - // Hydrate participants from relay (authoritative state). - if let Ok(all_members) = fetch_channel_members(ephemeral_channel_id, None, state).await { + if let Ok(all_members) = all_members_result { if !all_members.is_empty() { let mut hs = state.huddle()?; hs.participants = all_members; @@ -133,15 +137,23 @@ pub(crate) async fn maybe_start_stt_pipeline( // Drop the old pipeline OUTSIDE the lock — thread join happens here. drop(old_stt); - let (pipeline, text_rx) = - match stt::SttPipeline::new(model_dir, tts_active, tts_cancel, ptt_active_for_stt) { - Ok(p) => p, - Err(e) => { - let hs = state.huddle()?; - hs.stt_starting.store(false, Ordering::Release); - return Err(e); - } - }; + let constructed = tokio::task::spawn_blocking(move || { + stt::SttPipeline::new(model_dir, tts_active, tts_cancel, ptt_active_for_stt) + }) + .await; + let (pipeline, text_rx) = match constructed { + Ok(Ok(p)) => p, + Ok(Err(e)) => { + let hs = state.huddle()?; + hs.stt_starting.store(false, Ordering::Release); + return Err(e); + } + Err(e) => { + let hs = state.huddle()?; + hs.stt_starting.store(false, Ordering::Release); + return Err(format!("spawn_blocking failed: {e}")); + } + }; let pipeline = Arc::new(pipeline); { @@ -204,13 +216,22 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Arc::new(p), - Err(e) => { + let constructed = tokio::task::spawn_blocking(move || { + tts::TtsPipeline::new(model_dir, tts_active, tts_cancel, output_device) + }) + .await; + let pipeline = match constructed { + Ok(Ok(p)) => Arc::new(p), + Ok(Err(e)) => { let hs = state.huddle()?; hs.tts_starting.store(false, Ordering::Release); return Err(e); } + Err(e) => { + let hs = state.huddle()?; + hs.tts_starting.store(false, Ordering::Release); + return Err(format!("spawn_blocking failed: {e}")); + } }; { diff --git a/desktop/src-tauri/src/huddle/playout.rs b/desktop/src-tauri/src/huddle/playout.rs index 69548bcfcf..bf5d4a2390 100644 --- a/desktop/src-tauri/src/huddle/playout.rs +++ b/desktop/src-tauri/src/huddle/playout.rs @@ -334,6 +334,26 @@ pub(crate) async fn run_playout_recv_loop( } } } + Some("roster") => { + if let Some(peer_list) = v["peers"].as_array() { + let mut replacement = std::collections::HashMap::new(); + for p in peer_list { + if let (Some(pk), Some(idx)) = ( + p["pubkey"].as_str(), + p["peer_index"].as_u64(), + ) { + replacement.insert(idx as u8, pk.to_string()); + } + } + let identity_unchanged = |idx: &u8| { + replacement.get(idx) == index_to_pubkey.get(idx) + }; + peers.retain(|idx, _| identity_unchanged(idx)); + frame_counts.retain(|idx, _| identity_unchanged(idx)); + active_indices.retain(identity_unchanged); + index_to_pubkey = replacement; + } + } Some("left") => { if let Some(idx) = v["peer_index"].as_u64() { let key = idx as u8; diff --git a/desktop/src-tauri/src/huddle/reconnect.rs b/desktop/src-tauri/src/huddle/reconnect.rs new file mode 100644 index 0000000000..6996776f1c --- /dev/null +++ b/desktop/src-tauri/src/huddle/reconnect.rs @@ -0,0 +1,52 @@ +//! Audio-only huddle reconnection after an unexpected relay disconnect. + +use std::sync::atomic::Ordering; + +use tauri::State; + +use crate::app_state::AppState; + +use super::{relay_api, HuddlePhase}; + +/// Re-establish only the audio relay WebSocket after an unexpected owner/pod +/// disconnect. Huddle membership, mic capture, STT/TTS, and frontend state stay +/// live, so a successful reconnect is a short audio blip rather than a leave. +/// +/// The session generation and channel id are re-checked after the network dial: +/// an intentional leave/end racing this command wins and the newly-opened audio +/// pipeline is cancelled instead of resurrecting a terminal huddle. +#[tauri::command] +pub async fn reconnect_huddle_audio(state: State<'_, AppState>) -> Result<(), String> { + let (ephemeral_channel_id, parent_channel_id, session_generation) = { + let hs = state.huddle()?; + if matches!(hs.phase, HuddlePhase::Idle | HuddlePhase::Leaving) { + return Err("huddle is no longer active".into()); + } + ( + hs.ephemeral_channel_id + .clone() + .ok_or("active huddle has no channel id")?, + hs.parent_channel_id.clone(), + hs.session_generation.load(Ordering::Acquire), + ) + }; + + let (cancel, pcm_tx) = + relay_api::connect_audio_relay(&ephemeral_channel_id, parent_channel_id.as_deref(), &state) + .await?; + + let mut hs = state.huddle()?; + let still_current = !matches!(hs.phase, HuddlePhase::Idle | HuddlePhase::Leaving) + && hs.ephemeral_channel_id.as_deref() == Some(ephemeral_channel_id.as_str()) + && hs.session_generation.load(Ordering::Acquire) == session_generation; + if !still_current { + cancel.cancel(); + return Err("huddle ended while audio was reconnecting".into()); + } + + if let Some(old_cancel) = hs.audio_ws_cancel.replace(cancel) { + old_cancel.cancel(); + } + hs.audio_relay_pcm_tx = Some(pcm_tx); + Ok(()) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c8ff5cfbe5..7f4897b7d2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,6 +1,12 @@ +// Deep async call chains (mesh ensure→download→start under Tauri command +// futures) exceed the default query depth when computing layouts. +#![recursion_limit = "256"] + mod app_state; +mod archive; mod commands; mod deep_link; +mod event_sync; mod events; mod huddle; mod managed_agents; @@ -11,10 +17,12 @@ mod migration; #[cfg(test)] mod model_tests; mod models; +mod nostr_bind; pub mod nostr_convert; mod prevent_sleep; mod ptt_shortcut; mod relay; +mod reset; mod secret_store; mod shutdown; mod templates; @@ -31,23 +39,31 @@ use deep_link::handle_deep_link_url; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, }; +use huddle::reconnect::reconnect_huddle_audio; use huddle::{ add_agent_to_huddle, check_pipeline_hotstart, confirm_huddle_active, download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode, join_huddle, leave_huddle, push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, }; -use managed_agents::{ - backfill_persona_snapshots, ensure_nest, restore_managed_agents_on_launch, try_regenerate_nest, -}; +use managed_agents::{backfill_persona_snapshots, ensure_nest, try_regenerate_nest}; +#[cfg(all(feature = "mesh-llm", target_os = "macos"))] +use shutdown::hard_exit_after_mesh_shutdown; use shutdown::shutdown_managed_agents; +#[cfg(feature = "mesh-llm")] +use shutdown::shutdown_mesh_runtime; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; +#[cfg(target_os = "macos")] +use tauri::Listener; use tauri::{Emitter, Manager, RunEvent}; use tauri_plugin_window_state::StateFlags; +#[cfg(target_os = "macos")] +const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; + #[tauri::command] fn perform_sidebar_default_haptic() { #[cfg(target_os = "macos")] @@ -64,8 +80,185 @@ fn perform_sidebar_default_haptic() { } } +/// Performs the window action matching the macOS "double-click a window's +/// title bar to" preference (`AppleActionOnDoubleClick`). +/// +/// macOS values are `Minimize`, `Maximize` (default when unset), `Fill`, or +/// `None`. +/// The desktop app uses a web-based title-bar drag region, so the frontend +/// forwards double-clicks here and suppresses Tauri's injected drag-region +/// handler, whose default macOS path hardcodes maximize. +/// +/// For `Fill`, resize to the current monitor work area instead of using +/// Tauri's maximize path, which maps to macOS zoom for titled, resizable +/// windows. +/// +/// On non-macOS platforms this always toggles maximize (the historical +/// behavior). +#[tauri::command] +fn title_bar_double_click(window: tauri::Window) { + #[cfg(target_os = "macos")] + { + let action = { + let output = std::process::Command::new("defaults") + .args(["read", "-g", "AppleActionOnDoubleClick"]) + .output(); + match output { + Ok(output) if output.status.success() => { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + _ => "Maximize".to_string(), + } + }; + + match action.as_str() { + "None" => {} + "Minimize" => { + let _ = window.minimize(); + } + "Fill" => { + fill_window(&window); + } + // "Maximize" or any unexpected value. + _ => { + toggle_maximize(&window); + } + } + } + + #[cfg(not(target_os = "macos"))] + { + toggle_maximize(&window); + } +} + +/// Fills the current display work area, excluding system UI like the menu bar +/// and Dock. +#[cfg(target_os = "macos")] +fn fill_window(window: &tauri::Window) { + match window.current_monitor() { + Ok(Some(monitor)) => { + if window.is_maximized().unwrap_or(false) { + let _ = window.unmaximize(); + } + + let work_area = monitor.work_area(); + let _ = window.set_position(work_area.position); + let _ = window.set_size(work_area.size); + } + _ => { + let _ = window.maximize(); + } + } +} + +/// Toggles the window between maximized and its previous size, matching the +/// historical double-click behavior. +fn toggle_maximize(window: &tauri::Window) { + match window.is_maximized() { + Ok(true) => { + let _ = window.unmaximize(); + } + _ => { + let _ = window.maximize(); + } + } +} + +fn reveal_initial_window(window: &tauri::Window) { + if let Err(error) = window.show() { + eprintln!("buzz-desktop: failed to reveal main window: {error}"); + return; + } + if let Err(error) = window.set_focus() { + eprintln!("buzz-desktop: failed to focus main window: {error}"); + } +} + +#[cfg(target_os = "macos")] +fn set_initial_window_backing(window: &tauri::Window) { + // The window remains transparent at runtime for vibrancy. Use an opaque + // native backing only across the first visible frames so the previous app + // cannot show through before WebKit has submitted its first surface. + if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { + eprintln!("buzz-desktop: failed to set initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +async fn clear_initial_window_backing(window: &tauri::Window) { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if let Err(error) = window.set_background_color(None) { + eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +async fn wait_for_stable_initial_window_geometry(window: &tauri::Window) { + const MAX_POLLS: usize = 120; + const REQUIRED_STABLE_POLLS: usize = 4; + + let mut previous_bounds = None; + let mut stable_polls = 0; + + for _ in 0..MAX_POLLS { + // Accept whatever geometry the window-state plugin restores — maximized + // or a normal saved size. macOS applies the restore asynchronously, so + // we only need consecutive identical outer bounds to know it settled. + // Gating on `is_maximized()` here would leave `bounds` permanently + // `None` for restored non-maximized windows and stall the reveal until + // the poll timeout. + let bounds = match (window.outer_position(), window.outer_size()) { + (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)), + _ => None, + }; + + if bounds.is_some() && bounds == previous_bounds { + stable_polls += 1; + if stable_polls >= REQUIRED_STABLE_POLLS { + return; + } + } else { + stable_polls = 0; + } + previous_bounds = bounds; + + tokio::time::sleep(std::time::Duration::from_millis(16)).await; + } + + eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout"); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // mesh-llm's async chains (model download, node start/join) overflow + // tokio's default 2 MiB worker stacks — a stack-guard SIGABRT, not a + // panic. Upstream mesh-llm and mesh-console both run on 8 MiB worker + // stacks for this reason; give Tauri's command runtime the same headroom + // before anything else touches tauri::async_runtime. + #[cfg(feature = "mesh-llm")] + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(crate::mesh_llm::MESH_WORKER_STACK_SIZE) + .build() + { + Ok(runtime) => { + tauri::async_runtime::set(runtime.handle().clone()); + // Keep the runtime alive for the process lifetime; dropping it + // would shut down the workers Tauri now depends on. + std::mem::forget(runtime); + eprintln!( + "buzz-mesh: installed tokio runtime with {} MiB worker stacks", + crate::mesh_llm::MESH_WORKER_STACK_SIZE / (1024 * 1024) + ); + } + Err(error) => { + // Fall back to Tauri's default runtime: the app still works, + // only deep mesh-llm futures are at risk of stack overflow. + eprintln!("buzz-mesh: failed to build big-stack tokio runtime, using default: {error}"); + } + } + let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { // Focus the existing window when a duplicate instance launches. @@ -84,17 +277,59 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin( tauri_plugin_window_state::Builder::default() - // The main window should always launch edge-to-edge in the - // available desktop area. Do not let stale saved geometry or - // fullscreen state override the maximized launch config. - .with_state_flags( - StateFlags::all() - & !(StateFlags::VISIBLE - | StateFlags::POSITION - | StateFlags::SIZE - | StateFlags::MAXIMIZED - | StateFlags::FULLSCREEN), - ) + // Visibility is excluded: the native reveal plugin below + // shows the window after saved geometry has been restored. + .with_state_flags(StateFlags::all() & !StateFlags::VISIBLE) + .build(), + ) + .plugin( + tauri::plugin::Builder::<_, ()>::new("initial-window-reveal") + .on_webview_ready(|webview| { + if webview.label() != "main" { + return; + } + + // macOS applies the restored geometry asynchronously. Wait + // for several identical outer bounds and for React to + // commit the startup surface before revealing it. + let window = webview.window(); + + #[cfg(target_os = "macos")] + { + set_initial_window_backing(&window); + + let (initial_render_tx, initial_render_rx) = tokio::sync::oneshot::channel(); + window + .app_handle() + .once(INITIAL_RENDER_READY_EVENT, move |_| { + let _ = initial_render_tx.send(()); + }); + + tauri::async_runtime::spawn(async move { + wait_for_stable_initial_window_geometry(&window).await; + + if tokio::time::timeout( + std::time::Duration::from_secs(5), + initial_render_rx, + ) + .await + .is_err() + { + eprintln!( + "buzz-desktop: initial render did not commit before reveal timeout" + ); + } + + reveal_initial_window(&window); + clear_initial_window_backing(&window).await; + }); + } + + #[cfg(not(target_os = "macos"))] + { + reveal_initial_window(&window); + } + }) .build(), ) .plugin(tauri_plugin_websocket::init()) @@ -205,8 +440,6 @@ pub fn run() { #[cfg(not(buzz_updater_enabled))] let builder = builder; - let shutdown_started = Arc::new(AtomicBool::new(false)); - let restore_shutdown_started = Arc::clone(&shutdown_started); let app = builder .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); @@ -219,27 +452,75 @@ pub fn run() { .manage(commands::pairing::PairingHandle::new()) .setup(move |app| { let app_handle = app.handle().clone(); - let shutdown_started = Arc::clone(&restore_shutdown_started); + + // ── Phase 2: boot-time sentinel wipe ────────────────────────────── + // Must run before migrations and identity resolution so the wipe + // completes atomically on crash recovery. + // + // init_nest_dir is called early here (normally it runs inside + // run_boot_migrations) so reset::run_boot_reset can call nest_dir(). + let reset_outcome = if let Ok(data_dir) = app_handle.path().app_data_dir() { + let is_dev_for_reset = data_dir + .file_name() + .and_then(|n| n.to_str()) + .map(crate::migration::is_dev_data_dir_name) + .unwrap_or(false); + crate::managed_agents::init_nest_dir(is_dev_for_reset); + crate::reset::run_boot_reset(&data_dir) + } else { + crate::reset::ResetOutcome::default() + }; + + if reset_outcome.failed { + // Surface reset-failed state — skip identity resolution and + // all side-effecting setup. The webview still loads so the + // frontend can show the recovery screen via get_identity. + let state = app_handle.state::(); + state + .reset_failed + .store(true, std::sync::atomic::Ordering::Release); + return Ok(()); + } // Run all pre-identity data migrations before state loads from disk. - migration::run_boot_migrations(&app_handle); + if reset_outcome.completed { + migration::run_boot_migrations_after_reset(&app_handle); + } else { + migration::run_boot_migrations(&app_handle); + } // Resolve persisted identity key (env var → file → generate+save). // This is fatal — the app should not start with an ephemeral identity // that will be lost on restart, as that silently breaks channel // memberships, DMs, and relay identity. let state = app_handle.state::(); - resolve_persisted_identity(&app_handle, &state) - .map_err(|e| -> Box { e.into() })?; + if let Err(e) = resolve_persisted_identity(&app_handle, &state) { + eprintln!("buzz-desktop: fatal: identity resolution failed: {e}"); + std::process::exit(1); + } - // Sync team-dir edits and reconcile persona/team events. Needs the - // resolved owner keys, so it runs after identity resolution. - let owner_keys = state - .keys - .lock() - .map(|k| k.clone()) - .map_err(|e| -> Box { e.to_string().into() })?; - migration::run_event_sync(&app_handle, &owner_keys); + // When the identity is in recovery mode (lost = keyring empty after + // migration, or keyring-locked = keyring unreachable but marker + // present), all owner-keyed side effects (event sync, agent restore, + // relay publish) are skipped. The frontend shows a recovery screen; + // the user must relaunch after restoring the identity. + let identity_lost = state + .identity_lost + .load(std::sync::atomic::Ordering::Acquire); + let keyring_locked = state + .keyring_locked + .load(std::sync::atomic::Ordering::Acquire); + let recovery_mode = identity_lost || keyring_locked; + + // Snapshot owner keys after identity resolution; the best-effort + // event reconcile itself runs off the synchronous setup path below. + let owner_keys = match state.keys.lock() { + Ok(k) => k.clone(), + Err(e) => { + eprintln!("buzz-desktop: fatal: owner keys lock poisoned: {e}"); + std::process::exit(1); + } + }; // Backfill the pinned persona snapshot for any pre-existing agent // that predates the record-authoritative-spawn cutover (persona_id @@ -258,15 +539,18 @@ pub fn run() { *guard = Some(app_handle.clone()); } - // Bring up the runtime-owned relay-mesh call-me-now listener now, - // before any saved agent restore can request a connection. Its - // lifetime is tied to the runtime, not a UI mount — this is what - // closes the cold-launch hole-punch race. + // Bring up the runtime-owned shared-compute coordinator before + // saved agents are restored. Its lifetime is tied to the app, not + // a UI mount; it publishes discovery and reconciles membership for + // MeshLLM's native admission and transport. #[cfg(feature = "mesh-llm")] { + // Route mesh-llm's download progress (model weights, runtime) + // onto Tauri events so the UI can render real progress. + crate::mesh_llm::install_progress_sink(&app_handle); let mesh_app = app_handle.clone(); tauri::async_runtime::spawn(async move { - crate::mesh_llm::spawn_listener(mesh_app).await; + crate::mesh_llm::start_coordinator(mesh_app).await; }); } @@ -311,7 +595,9 @@ pub fn run() { // destination is present. Non-fatal. // On a real migration, emit a one-time hint so the user can delete // the now-inert ~/.sprout; the frontend dedupes the toast. - if migration::migrate_legacy_nest() { + // Suppressed when a reset completed this boot: the nest was wiped and + // a fresh ~/.sprout-less state is exactly what we want. + if !reset_outcome.completed && migration::migrate_legacy_nest() { let _ = app_handle.emit("legacy-nest-migrated", ()); } @@ -319,10 +605,12 @@ pub fn run() { // from the shared ~/.buzz nest into the new dedicated ~/.buzz-dev // nest so no work is lost when the nest is first namespaced. // Runs only when nest_dir() resolved to ~/.buzz-dev (dev instance). + // Suppressed after a reset so re-importing ~/.buzz into ~/.buzz-dev + // doesn't re-populate what was just wiped. let is_dev_nest = managed_agents::nest_dir() .and_then(|p| p.file_name().map(|n| n.to_os_string())) .is_some_and(|n| n == ".buzz-dev"); - if is_dev_nest { + if !reset_outcome.completed && is_dev_nest { migration::migrate_dev_nest(); } @@ -330,7 +618,7 @@ pub fn run() { // bundled CLI binary. Non-fatal: agents find CLI via PATH. if let Ok(exe) = std::env::current_exe() { if let Some(parent) = exe.parent() { - if let Err(error) = managed_agents::ensure_cli_symlink(parent) { + if let Err(error) = managed_agents::ensure_cli_symlink(parent, is_dev_nest) { eprintln!("buzz-desktop: failed to create CLI symlink: {error}"); } } @@ -338,6 +626,15 @@ pub fn run() { try_regenerate_nest(&app_handle); + // Sync team-dir edits and reconcile persona/team/agent events after + // setup can continue. It is best-effort retention backfill, unlike + // identity resolution above, so JSON/SQLite/signing work must not + // hold the boot path hostage. Skipped in recovery mode — the owner + // key is ephemeral. + if !recovery_mode { + event_sync::spawn_event_sync(app_handle.clone(), owner_keys); + } + if let Some(mgr) = huddle::models::global_model_manager() { mgr.start_stt_download(state.http_client.clone()); mgr.start_tts_download(state.http_client.clone()); @@ -357,20 +654,16 @@ pub fn run() { }); } - // Keep launch-time agent restoration off the synchronous setup path - // so the frontend can mount and reveal the window promptly. Gated on - // the boot-time repos symlink result (see restore_agents above): - // skip when a configured repos_dir could not be resolved, so no - // agent clones into a REPOS that isn't the user's target. - if restore_agents { - tauri::async_runtime::spawn(async move { - if let Err(error) = - restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref()) - .await - { - eprintln!("buzz-desktop: failed to restore managed agents: {error}"); - } - }); + // Defer launch-time agent restoration until `apply_workspace` has + // installed the active workspace relay and identity. Starting here + // would race React initialization and send agents whose saved record + // has no relay override to the localhost fallback. Preserve the + // boot-time repos and identity recovery safety gates by only marking + // restoration pending when both allow it. + if restore_agents && !recovery_mode { + state + .managed_agent_restore_pending + .store(true, Ordering::Release); } // Periodic sweep: reap orphaned agents from dead instances every 60s. @@ -417,38 +710,46 @@ pub fn run() { // One loop is the sole publisher for persona, team, and managed- // agent writers; a relay-unreachable tick leaves rows pending for // the next sweep. - let flush_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - use std::time::Duration; - use tauri::Manager; - let Ok(db_path) = managed_agents::managed_agents_base_dir(&flush_handle) - .map(|d| d.join("retention.db")) - else { - eprintln!("buzz-desktop: event-flush: cannot resolve retention db path"); - return; - }; - loop { - let state = flush_handle.state::(); - if let Err(e) = - managed_agents::persona_events::flush_pending_events(&db_path, &state).await - { - eprintln!("buzz-desktop: event-flush: {e}"); + // Skipped in recovery mode — flushing under an ephemeral key would + // publish events attributed to an identity the user doesn't own. + if !recovery_mode { + let flush_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + use std::time::Duration; + use tauri::Manager; + let Ok(db_path) = managed_agents::managed_agents_base_dir(&flush_handle) + .map(|d| d.join("retention.db")) + else { + eprintln!("buzz-desktop: event-flush: cannot resolve retention db path"); + return; + }; + loop { + let state = flush_handle.state::(); + if let Err(e) = + managed_agents::persona_events::flush_pending_events(&db_path, &state) + .await + { + eprintln!("buzz-desktop: event-flush: {e}"); + } + tokio::time::sleep(Duration::from_secs(30)).await; } - tokio::time::sleep(Duration::from_secs(30)).await; - } - }); + }); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ + title_bar_double_click, get_identity, get_nsec, import_identity, + persist_current_identity, get_profile, update_profile, get_user_profile, get_users_batch, get_user_notes, + get_git_identity, get_project_repo_snapshot, get_project_repo_diff, get_project_local_repo_diff, @@ -467,9 +768,12 @@ pub fn run() { get_media_proxy_port, fetch_link_preview_title, discover_acp_providers, + discover_git_bash_prerequisite, install_acp_runtime, discover_managed_agent_prereqs, sign_event, + sign_nostr_identity_binding, + sign_out, decrypt_observer_event, build_observer_control_event, create_auth_event, @@ -511,9 +815,14 @@ pub fn run() { show_native_notification, upload_media, pick_and_upload_media, + pick_and_upload_image, upload_media_bytes, download_image, download_file, + fetch_media_bytes, + copy_image_to_clipboard, + copy_text_to_clipboard, + fetch_snapshot_bytes, list_relay_members, get_my_relay_membership, add_relay_member, @@ -522,6 +831,7 @@ pub fn run() { archive_identity, unarchive_identity, list_archived_identities, + get_relay_self, resolve_oa_owner, list_relay_agents, list_managed_agents, @@ -529,22 +839,23 @@ pub fn run() { start_managed_agent, stop_managed_agent, set_managed_agent_start_on_app_launch, + set_managed_agent_auto_restart, delete_managed_agent, get_managed_agent_log, get_agent_models, discover_agent_models, get_agent_config_surface, + get_runtime_file_config, + get_baked_build_env_keys, + get_baked_build_env, put_agent_session_config, - mesh_availability, + get_global_agent_config, + set_global_agent_config, mesh_start_node, - mesh_ensure_client_node, - mesh_prepare_relay_mesh_client, - mesh_dial_endpoint_addr, - mesh_status_report_payload, mesh_stop_node, mesh_node_status, mesh_installed_models, - mesh_agent_preset, + mesh_model_catalog, update_managed_agent, discover_backend_providers, probe_backend_provider, @@ -563,14 +874,16 @@ pub fn run() { create_team, update_team, delete_team, - install_team_from_directory, - sync_team_directory, - pick_team_directory, - export_team_to_json, - parse_team_file, - parse_persona_files, - export_persona_to_json, + export_agent_snapshot, + preview_agent_snapshot_import, + confirm_agent_snapshot_import, + encode_agent_snapshot_for_send, + export_team_snapshot, + encode_team_snapshot_for_send, + preview_team_snapshot_import, + confirm_team_snapshot_import, get_channel_workflows, + get_channels_workflows, get_workflow, create_workflow, update_workflow, @@ -594,6 +907,7 @@ pub fn run() { end_huddle, get_huddle_state, push_audio_pcm, + reconnect_huddle_audio, start_stt_pipeline, set_huddle_transcription_enabled, download_voice_models, @@ -621,45 +935,52 @@ pub fn run() { get_agent_memory, relay_reconnect_hook, relay_reconnect_hook_configured, + observer_archive_default_enabled, + agent_metric_archive_default_enabled, + archive::archive_events, + archive::create_save_subscription, + archive::merge_save_subscription_kinds, + archive::remove_save_subscription_kind, + archive::list_save_subscriptions, + archive::delete_save_subscription, + archive::read_archived_events, + archive::read_archived_observer_events_for_channel, + archive::index_observer_channel_id, + archive::read_unindexed_observer_rows, is_auto_update_supported, + set_window_vibrancy, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); let shutdown_done = Arc::new(AtomicBool::new(false)); - // Agent cleanup on SIGINT (Ctrl+C), SIGTERM, and SIGHUP (terminal close). - // The ctrlc crate with the "termination" feature covers all three signals - // and runs the handler on a dedicated thread (safe for mutex operations). - // `shutdown_done` prevents double-execution with the RunEvent handler. - // `process::exit(0)` intentionally skips Drop impls to avoid re-entrant - // locking in destructors during signal teardown. #[cfg(unix)] - { - let signal_app = app.handle().clone(); - let signal_shutdown_done = Arc::clone(&shutdown_done); - let signal_shutdown_started = Arc::clone(&shutdown_started); - if let Err(e) = ctrlc::set_handler(move || { - signal_shutdown_started.store(true, Ordering::SeqCst); - if !signal_shutdown_done.swap(true, Ordering::SeqCst) { - let _ = shutdown_managed_agents(&signal_app); - } - std::process::exit(0); - }) { - eprintln!("buzz-desktop: failed to register signal handler: {e}"); - } - } + shutdown::install_signal_handler(app.handle().clone(), Arc::clone(&shutdown_done)); let run_shutdown_done = Arc::clone(&shutdown_done); app.run(move |app_handle, event| match event { RunEvent::ExitRequested { .. } | RunEvent::Exit => { - shutdown_started.store(true, Ordering::SeqCst); + app_handle + .state::() + .shutdown_started + .store(true, Ordering::SeqCst); if !run_shutdown_done.swap(true, Ordering::SeqCst) { prevent_sleep::release(&app_handle.state::().prevent_sleep); if let Err(error) = shutdown_managed_agents(app_handle) { eprintln!("buzz-desktop: failed to stop managed agents: {error}"); } + #[cfg(feature = "mesh-llm")] + shutdown_mesh_runtime(app_handle); } + + // AppKit terminates through libc exit(), which runs C++ static + // destructors. The embedded ggml/Metal runtime currently aborts in + // that destructor phase even after its node has stopped cleanly. + // End the process only after Buzz and Mesh shutdown above, while + // deliberately skipping those native global destructors. + #[cfg(all(feature = "mesh-llm", target_os = "macos"))] + hard_exit_after_mesh_shutdown(); } _ => {} }); diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index fbc7d7b502..ba4407d164 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -45,8 +45,6 @@ pub struct ManagedAgentEventContent { pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mcp_toolsets: Option, /// `persona_content_hash` of the persona snapshot pinned at create time. /// Public drift indicator (not a secret) — lets other clients flag a stale /// snapshot without re-reading the source persona. @@ -70,14 +68,38 @@ pub struct ManagedAgentEventContent { /// operational start/stop produces an identical projection and never /// republishes. pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventContent { + // Slimmed projection (NIP-AP "Slimming: kind:30177"): definition-linked + // instances resolve prompt/model/provider/source_version through their + // kind:30175 definition, so those fields are omitted from the wire. + // Definition-less instances ARE their own definition and keep emitting + // the quad — old readers parse a slimmed event successfully and would + // otherwise overwrite their local snapshot with absent values, with no + // restore path. This branch retires once every record is + // definition-backed (B5 backfill). + let definition_linked = record.persona_id.is_some(); ManagedAgentEventContent { name: record.name.clone(), persona_id: record.persona_id.clone(), - system_prompt: record.system_prompt.clone(), - model: record.model.clone(), - provider: record.provider.clone(), - mcp_toolsets: record.mcp_toolsets.clone(), - persona_source_version: record.persona_source_version.clone(), + system_prompt: if definition_linked { + None + } else { + record.system_prompt.clone() + }, + model: if definition_linked { + None + } else { + record.model.clone() + }, + provider: if definition_linked { + None + } else { + record.provider.clone() + }, + persona_source_version: if definition_linked { + None + } else { + record.persona_source_version.clone() + }, parallelism: record.parallelism, respond_to: record.respond_to, respond_to_allowlist: record.respond_to_allowlist.clone(), @@ -156,9 +178,9 @@ mod tests { model: Some("claude-opus-4".to_string()), provider: Some("anthropic".to_string()), persona_source_version: Some("abc123".to_string()), - mcp_toolsets: Some("default".to_string()), env_vars: BTreeMap::from([("OPENAI_API_KEY".to_string(), "sk-secret".to_string())]), start_on_app_launch: true, + auto_restart_on_config_change: true, runtime_pid: Some(4242), backend: super::super::BackendKind::Provider { id: "buzz-backend-x".to_string(), @@ -166,6 +188,7 @@ mod tests { }, backend_agent_id: Some("remote-id".to_string()), provider_binary_path: Some("/path/to/binary".to_string()), + team_id: None, persona_team_dir: None, persona_name_in_team: None, created_at: "2025-01-01T00:00:00Z".to_string(), @@ -174,8 +197,22 @@ mod tests { last_stopped_at: Some("2025-01-03T00:00:00Z".to_string()), last_exit_code: Some(0), last_error: Some("some runtime error".to_string()), + last_error_code: None, respond_to: RespondTo::Allowlist, respond_to_allowlist: vec!["79be667e".to_string()], + // Unified-model fields carry real values so the exclusion test + // proves they are absent from the wire, not vacuously empty. + display_name: Some("Display Name Secretish".to_string()), + slug: Some("sample-slug".to_string()), + runtime: Some("goose".to_string()), + name_pool: vec!["poolname".to_string()], + is_builtin: true, + is_active: false, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, relay_mesh: None, } } @@ -237,6 +274,16 @@ mod tests { assert!(!json.contains("last_exit_code")); assert!(!json.contains("last_error")); assert!(!json.contains("some runtime error")); + + // Unified-agent-model fields (Phase 1A) — deliberately NOT published + // yet; adding them to the wire projection is a Phase 2 (relay + // canonicalization) decision, not a record-shape side effect. + assert!(!json.contains("display_name"), "leaked display_name"); + assert!(!json.contains("\"slug\""), "leaked slug"); + assert!(!json.contains("\"runtime\""), "leaked runtime"); + assert!(!json.contains("name_pool"), "leaked name_pool"); + assert!(!json.contains("is_builtin"), "leaked is_builtin"); + assert!(!json.contains("is_active"), "leaked is_active"); assert!(!json.contains("backend_agent_id")); assert!(!json.contains("provider_binary_path")); assert!(!json.contains("relay_url")); @@ -245,7 +292,40 @@ mod tests { assert!(json.contains("\"name\"")); assert!(json.contains("Test Agent")); assert!(json.contains("persona_id")); - assert!(json.contains("system_prompt")); + // Slimmed projection: a definition-linked record resolves its prompt + // through the definition, so the wire must NOT carry it. + assert!( + !json.contains("system_prompt"), + "definition-linked projection must omit system_prompt" + ); + } + + /// Slimming (NIP-AP): definition-linked records omit the definition quad; + /// definition-less records keep emitting it (they ARE their own + /// definition — old readers would otherwise wipe fields with no restore + /// path). + #[test] + fn projection_slims_definition_quad_only_when_linked() { + let linked = sample_agent(); // persona_id: Some + let json = serde_json::to_string(&agent_event_content(&linked)).unwrap(); + assert!(!json.contains("system_prompt")); + assert!(!json.contains("\"model\"")); + assert!(!json.contains("\"provider\"")); + assert!(!json.contains("persona_source_version")); + // Instance fields stay on the wire. + assert!(json.contains("parallelism")); + assert!(json.contains("respond_to")); + + let mut standalone = sample_agent(); + standalone.persona_id = None; + let json = serde_json::to_string(&agent_event_content(&standalone)).unwrap(); + assert!(json.contains("system_prompt"), "standalone keeps prompt"); + assert!(json.contains("\"model\""), "standalone keeps model"); + assert!(json.contains("\"provider\""), "standalone keeps provider"); + assert!( + json.contains("persona_source_version"), + "standalone keeps source_version" + ); } #[test] @@ -276,10 +356,29 @@ mod tests { #[test] fn projection_changes_on_meaningful_edit() { + // Instance-level edit surfaces for every record. let agent = sample_agent(); let mut edited = agent.clone(); - edited.system_prompt = Some("A different prompt.".to_string()); + edited.parallelism += 1; assert_ne!(agent_event_content(&agent), agent_event_content(&edited)); + + // Definition-level edit surfaces only for definition-less records — + // linked records resolve the prompt through their definition. + let mut standalone = sample_agent(); + standalone.persona_id = None; + let mut edited = standalone.clone(); + edited.system_prompt = Some("A different prompt.".to_string()); + assert_ne!( + agent_event_content(&standalone), + agent_event_content(&edited) + ); + let mut linked_edit = sample_agent(); + linked_edit.system_prompt = Some("A different prompt.".to_string()); + assert_eq!( + agent_event_content(&sample_agent()), + agent_event_content(&linked_edit), + "a linked record's local prompt snapshot is not wire state" + ); } /// The inbound structural guard: a foreign event whose content JSON crams diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs new file mode 100644 index 0000000000..b0bf8f5991 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -0,0 +1,987 @@ +//! `buzz-agent-snapshot v1` — manifest type, encoder, and decoder stubs. +//! +//! An agent snapshot is a portable, shareable representation of an agent +//! definition. It captures: +//! - **definition** — behavioral config (prompt, runtime, model, …) +//! - **profile** — kind:0 presentation (name, about, avatar) +//! - **memory** — optional, owner-decrypted engrams at one of three levels +//! +//! Two encodings are supported: +//! - `.agent.json` — canonical snapshot manifest +//! - `.agent.png` — avatar image with manifest in a `buzz_agent_snapshot` +//! tEXt chunk +//! +//! Both formats may carry memory at any level. Memory entries are plaintext, +//! so callers must require an explicit opt-in before exporting them. +//! +//! **Zip is NOT in v1** — deferred to v2 for skills bundling. +//! +//! # Secret exclusion +//! +//! The following fields are NEVER serialized: +//! - `private_key_nsec` / any private key material +//! - `auth_tag` (NIP-OA) +//! - `env_vars` (API keys / credentials) +//! - `relay_url` (machine-local endpoint) +//! - `acp_command` / `agent_command` / `agent_command_override` / `agent_args` +//! (machine-local harness paths) +//! - `mcp_command` (machine-local) +//! - runtime state: `runtime_pid`, `backend_agent_id`, `backend` blob, +//! `provider_binary_path`, `last_*` +//! - lineage ids: `persona_id`, `team_id`, `source_team`, `source_team_persona_slug`, +//! `persona_source_version` +//! - internal bookkeeping: `start_on_app_launch`, +//! `auto_restart_on_config_change`, `is_builtin` +//! +//! These exclusions are enforced by construction (only explicit fields are +//! placed into `AgentSnapshotDefinition`) and asserted by unit tests. + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use png::{BitDepth, ColorType, Decoder, Encoder}; +use serde::{Deserialize, Serialize}; +use std::io::Cursor; + +use crate::managed_agents::types::ManagedAgentRecord; + +// ── Constants ──────────────────────────────────────────────────────────────── + +/// tEXt chunk keyword used in `.agent.png` files. +pub const PNG_CHUNK_KEYWORD: &str = "buzz_agent_snapshot"; + +/// Maximum avatar size (bytes) to inline as a data URL. Avatars larger than +/// this are stored as a URL reference instead. +const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024; // 2 MB + +/// Format discriminator — used for sniffing and validation. +pub const FORMAT_DISCRIMINATOR: &str = "buzz-agent-snapshot"; + +/// Version of the manifest format produced by this module. +pub const FORMAT_VERSION: u32 = 1; + +// ── Memory level ───────────────────────────────────────────────────────────── + +/// How much memory to bundle in the snapshot. +/// +/// The default is `None` — config-only export, safest for sharing. Memory +/// entries are plaintext in the output file; users must opt in explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryLevel { + /// Export definition + profile only. No memory. (Default) + #[default] + None, + /// Export definition + profile + `core` memory only. + Core, + /// Export definition + profile + `core` + all `mem/*` entries. + Everything, +} + +// ── Manifest sub-types ──────────────────────────────────────────────────────── + +/// Behavioral definition — what makes the agent do what it does. +/// +/// Fields mirror `ManagedAgentRecord` definition-level fields. Only the subset +/// meaningful across environments is included; machine-local / secret fields +/// are deliberately absent. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotDefinition { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parallelism: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respond_to: Option, + /// Allowlist entries. These are flagged during import — they come from the + /// source environment and are meaningless on the importer's relay. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub name_pool: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_timeout_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_turn_duration_seconds: Option, +} + +/// kind:0 presentation fields. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotProfile { + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, + /// Avatar inlined as a `data:image/...;base64,…` URI (≤ 2 MB), + /// or a URL fallback if the image exceeds the size limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_data_url: Option, + /// Present when the avatar exceeds MAX_AVATAR_INLINE_BYTES and is stored + /// by reference rather than inlined. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, +} + +/// A single decrypted memory entry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotMemoryEntry { + pub slug: String, + pub body: String, +} + +/// Memory section of the manifest. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshotMemory { + /// Indicates what was included at export time. + pub level: MemoryLevel, + /// Decrypted memory entries. Empty when `level == None`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub entries: Vec, +} + +// ── Top-level manifest ──────────────────────────────────────────────────────── + +/// The top-level `buzz-agent-snapshot v1` manifest. +/// +/// Serializes to / from JSON. Embedded in `.agent.json` directly, or in the +/// `buzz_agent_snapshot` tEXt chunk of a `.agent.png` (base64-encoded). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSnapshot { + /// Fixed discriminator for format sniffing. + pub format: String, + /// Schema version. This module produces version 1. + pub version: u32, + pub definition: AgentSnapshotDefinition, + pub profile: AgentSnapshotProfile, + pub memory: AgentSnapshotMemory, +} + +// ── Builder / encoder ──────────────────────────────────────────────────────── + +/// Materialize a snapshot manifest from a `ManagedAgentRecord`. +/// +/// `memory_entries` is the pre-fetched, owner-decrypted set from +/// `get_agent_memory`; this function does NOT call the Tauri command — that +/// is the caller's responsibility so this fn stays pure and testable. +/// +/// `memory_level` controls what ends up in the `memory` section. `avatar_bytes` +/// is the raw image for the agent (loaded from disk or fetched); when `None` +/// or too large, falls back to the `avatar_url` string on the record. +pub fn build_snapshot( + record: &ManagedAgentRecord, + memory_level: MemoryLevel, + memory_entries: Vec, + avatar_bytes: Option<&[u8]>, +) -> AgentSnapshot { + // ── Definition ───────────────────────────────────────────────────── + // Use definition-level fields (respond_to, allowlist, parallelism) for + // portability — instance-level equivalents are spawn-time snapshots and + // would be stale. + let definition = AgentSnapshotDefinition { + name: record + .display_name + .clone() + .unwrap_or_else(|| record.name.clone()), + system_prompt: record.system_prompt.clone(), + runtime: record.runtime.clone(), + model: record.model.clone(), + provider: record.provider.clone(), + parallelism: record.definition_parallelism.or(Some(record.parallelism)), + respond_to: record.definition_respond_to.clone(), + respond_to_allowlist: record.definition_respond_to_allowlist.clone(), + name_pool: record.name_pool.clone(), + idle_timeout_seconds: record.idle_timeout_seconds, + max_turn_duration_seconds: record.max_turn_duration_seconds, + }; + + // ── Profile ───────────────────────────────────────────────────────── + let (avatar_data_url, avatar_url_ref) = resolve_avatar(record, avatar_bytes); + let profile = AgentSnapshotProfile { + display_name: record + .display_name + .clone() + .unwrap_or_else(|| record.name.clone()), + about: None, // kind:0 `about` not yet surfaced in ManagedAgentRecord + avatar_data_url, + avatar_url: avatar_url_ref, + }; + + // ── Memory ───────────────────────────────────────────────────────── + let memory = AgentSnapshotMemory { + level: memory_level, + entries: memory_entries, + }; + + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition, + profile, + memory, + } +} + +/// Resolve the avatar for export. +/// +/// Returns `(data_url, url_ref)`: +/// - `data_url` is set when the avatar fits within `MAX_AVATAR_INLINE_BYTES`. +/// - `url_ref` is set when we can only record a URL (too large / no bytes). +fn resolve_avatar( + record: &ManagedAgentRecord, + avatar_bytes: Option<&[u8]>, +) -> (Option, Option) { + if let Some(bytes) = avatar_bytes { + if bytes.len() <= MAX_AVATAR_INLINE_BYTES { + // Detect MIME type from magic bytes. + let mime = if bytes.starts_with(b"\x89PNG") { + "image/png" + } else if bytes.starts_with(b"\xff\xd8\xff") { + "image/jpeg" + } else if bytes.starts_with(b"GIF8") { + "image/gif" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + "image/webp" + } else { + "image/png" // safe default for unknown + }; + let data_url = format!("data:{};base64,{}", mime, STANDARD.encode(bytes)); + return (Some(data_url), None); + } + } + // Fall back to URL reference (caller provided a URL avatar or bytes were + // too large). + let url_ref = record + .avatar_url + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + (None, url_ref) +} + +// ── JSON encoding / decoding ────────────────────────────────────────────────── + +/// Encode the manifest to pretty-printed JSON bytes. +pub fn encode_snapshot_json(snapshot: &AgentSnapshot) -> Result, String> { + serde_json::to_vec_pretty(snapshot).map_err(|e| format!("Failed to serialize snapshot: {e}")) +} + +/// Decode a manifest from JSON bytes. +pub fn decode_snapshot_json(bytes: &[u8]) -> Result { + let snapshot: AgentSnapshot = + serde_json::from_slice(bytes).map_err(|e| format!("Invalid snapshot JSON: {e}"))?; + validate_snapshot(&snapshot)?; + Ok(snapshot) +} + +// ── PNG encoding / decoding ─────────────────────────────────────────────────── + +/// Encode a snapshot into a `.agent.png` — avatar as the image body, manifest +/// in the `buzz_agent_snapshot` tEXt chunk. +pub fn encode_snapshot_png( + snapshot: &AgentSnapshot, + avatar_bytes: Option<&[u8]>, +) -> Result, String> { + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { + return Err( + "Cannot write a snapshot with memory.level 'none' and non-empty memory entries." + .to_string(), + ); + } + + // Manifest → JSON → base64 for the tEXt chunk payload. + let json_bytes = encode_snapshot_json(snapshot)?; + let chunk_text = STANDARD.encode(&json_bytes); + + // Use the avatar as the PNG image body, transcoding decodable non-PNG + // avatars. Fall back to a minimal 1×1 transparent placeholder only when + // there is no avatar or it cannot be decoded. + let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) { + Some(bytes) => { + let encoded_avatar = if bytes.starts_with(b"\x89PNG") { + inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| { + transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) + }) + } else { + transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) + }; + + match encoded_avatar { + Ok(png_bytes) => png_bytes, + Err(_) => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?, + } + } + None => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?, + }; + + Ok(png_bytes) +} + +/// Decode a manifest from a `.agent.png` tEXt chunk. +pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { + let decoder = Decoder::new(Cursor::new(png_bytes)); + let reader = decoder + .read_info() + .map_err(|e| format!("Invalid PNG: {e}"))?; + let info = reader.info(); + + let chunk_text = info + .uncompressed_latin1_text + .iter() + .find(|c| c.keyword == PNG_CHUNK_KEYWORD) + .map(|c| c.text.as_str()) + .ok_or_else(|| "PNG does not contain a buzz_agent_snapshot tEXt chunk".to_string())?; + + let json_bytes = STANDARD + .decode(chunk_text.trim()) + .map_err(|e| format!("Invalid base64 in PNG chunk: {e}"))?; + + decode_snapshot_json(&json_bytes) +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +/// Validate that the manifest has the correct format/version and required +/// fields. Returns an error string on failure. +pub(crate) fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String> { + if snapshot.format != FORMAT_DISCRIMINATOR { + return Err(format!( + "Unsupported snapshot format: {:?} (expected {:?})", + snapshot.format, FORMAT_DISCRIMINATOR + )); + } + if snapshot.version != 1 { + return Err(format!( + "Unsupported snapshot version: {} (expected 1)", + snapshot.version + )); + } + if snapshot.definition.name.trim().is_empty() { + return Err("Snapshot definition.name is empty".to_string()); + } + if snapshot.profile.display_name.trim().is_empty() { + return Err("Snapshot profile.displayName is empty".to_string()); + } + Ok(()) +} + +// ── PNG helpers ─────────────────────────────────────────────────────────────── + +/// Decode a `data:;base64,` URL back to raw bytes. +/// Returns `None` if `url` is not a data URL or decoding fails. +pub fn decode_avatar_data_url(url: &str) -> Option> { + let rest = url.strip_prefix("data:")?; + let comma_pos = rest.find(',')?; + let header = &rest[..comma_pos]; + let b64 = &rest[comma_pos + 1..]; + if !header.contains("base64") { + return None; + } + STANDARD.decode(b64.trim()).ok() +} + +/// Build a minimal 1×1 transparent PNG with a single tEXt chunk. +pub(crate) fn make_png_with_text(keyword: &str, text: &str) -> Result, String> { + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk(keyword.to_string(), text.to_string()) + .map_err(|e| format!("Failed to add tEXt chunk: {e}"))?; + let mut w = enc + .write_header() + .map_err(|e| format!("Failed to write PNG header: {e}"))?; + w.write_image_data(&[0, 0, 0, 0]) + .map_err(|e| format!("Failed to write PNG image data: {e}"))?; + } + Ok(buf) +} + +/// Transcode a decodable avatar to PNG and add the snapshot manifest chunk. +fn transcode_avatar_to_png_with_text( + avatar_bytes: &[u8], + keyword: &str, + text: &str, +) -> Result, String> { + let image = image::load_from_memory(avatar_bytes) + .map_err(|e| format!("Failed to decode avatar image: {e}"))?; + let mut png_bytes = Vec::new(); + image + .write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png) + .map_err(|e| format!("Failed to encode avatar as PNG: {e}"))?; + inject_text_chunk(&png_bytes, keyword, text) +} + +/// Inject a tEXt chunk into an existing PNG by re-encoding it. +/// +/// Re-decodes the image data via the `png` crate and writes a fresh PNG with +/// the extra chunk inserted after IHDR. This preserves the image content while +/// adding our metadata. +fn inject_text_chunk(png_bytes: &[u8], keyword: &str, text: &str) -> Result, String> { + let decoder = Decoder::new(Cursor::new(png_bytes)); + let mut reader = decoder + .read_info() + .map_err(|e| format!("Failed to decode source PNG: {e}"))?; + + let info = reader.info().clone(); + let width = info.width; + let height = info.height; + let color_type = info.color_type; + let bit_depth = info.bit_depth; + let buf_size = reader + .output_buffer_size() + .ok_or_else(|| "PNG output buffer size unavailable".to_string())?; + let mut pixel_buf = vec![0u8; buf_size]; + reader + .next_frame(&mut pixel_buf) + .map_err(|e| format!("Failed to read PNG frame: {e}"))?; + + let mut out = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut out), width, height); + enc.set_color(color_type); + enc.set_depth(bit_depth); + enc.add_text_chunk(keyword.to_string(), text.to_string()) + .map_err(|e| format!("Failed to add tEXt chunk: {e}"))?; + let mut w = enc + .write_header() + .map_err(|e| format!("Failed to write PNG header: {e}"))?; + w.write_image_data(&pixel_buf) + .map_err(|e| format!("Failed to write PNG pixel data: {e}"))?; + } + Ok(out) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; + use std::collections::BTreeMap; + + /// Build a minimal `ManagedAgentRecord` for testing. Only the fields + /// relevant to snapshot export are filled; the rest use defaults. + fn minimal_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "deadbeef".to_string(), + name: "Test Agent".to_string(), + display_name: Some("Test Agent Display".to_string()), + persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot + team_id: Some("SENTINEL_TEAM_ID".to_string()), // MUST NOT appear in snapshot + private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear in snapshot + auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot + relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot + avatar_url: Some("https://example.com/avatar.png".to_string()), + acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot + agent_command: "goose".to_string(), // MUST NOT appear in snapshot + agent_command_override: Some("goose-override".to_string()), // MUST NOT appear + agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot + mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot + turn_timeout_seconds: 120, // deprecated, MUST NOT appear + idle_timeout_seconds: Some(30), + max_turn_duration_seconds: Some(600), + parallelism: 2, + system_prompt: Some("You are a test agent.".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + persona_source_version: Some("v1.0".to_string()), // MUST NOT appear + env_vars: { + let mut m = BTreeMap::new(); + m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear + m + }, + start_on_app_launch: true, + auto_restart_on_config_change: true, + runtime_pid: Some(12345), // MUST NOT appear + backend: BackendKind::Provider { + // MUST NOT appear — carries a provider secret + id: "SENTINEL_BACKEND_ID".to_string(), + config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}), + }, + backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear + provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear + persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear + persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + last_started_at: Some("2024-01-03T00:00:00Z".to_string()), // MUST NOT appear + last_stopped_at: None, + last_exit_code: Some(0), // MUST NOT appear + last_error: Some("SENTINEL_LAST_ERROR".to_string()), // MUST NOT appear + last_error_code: Some(42), // MUST NOT appear + respond_to: RespondTo::default(), + respond_to_allowlist: vec!["pubkey1hex".to_string()], + slug: Some("test-agent".to_string()), + runtime: Some("goose".to_string()), + name_pool: vec!["Alice".to_string(), "Bob".to_string()], + is_builtin: false, + is_active: true, + source_team: Some("team-id-123".to_string()), // MUST NOT appear + source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear + definition_respond_to: Some("allowlist".to_string()), + definition_respond_to_allowlist: vec!["abc123def".to_string()], + definition_parallelism: Some(4), + relay_mesh: None, + } + } + + // ── Round-trip tests ────────────────────────────────────────────────────── + + #[test] + fn json_round_trip_config_only() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); + } + + #[test] + fn json_round_trip_with_memory() { + let record = minimal_record(); + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "I am a test agent.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/research".to_string(), + body: "Some research notes.".to_string(), + }, + ]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); + } + + #[test] + fn png_round_trip_no_memory() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.definition.name, snapshot.definition.name); + assert_eq!(parsed.profile.display_name, snapshot.profile.display_name); + assert_eq!(parsed.memory.level, MemoryLevel::None); + } + + #[test] + fn png_round_trip_with_avatar_png() { + // Build a minimal PNG avatar. + let avatar = make_png_with_text("dummy", "value").unwrap(); + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); + // Avatar should be inlined as a data URL. + assert!(snapshot + .profile + .avatar_data_url + .as_deref() + .unwrap_or("") + .starts_with("data:image/png;base64,")); + + let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.definition.name, snapshot.definition.name); + } + + #[test] + fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { + let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 3, + 2, + image::Rgb([0x12, 0x34, 0x56]), + )); + let mut jpeg_bytes = Vec::new(); + avatar + .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) + .unwrap(); + + let snapshot = build_snapshot( + &minimal_record(), + MemoryLevel::None, + vec![], + Some(&jpeg_bytes), + ); + let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(); + let decoder = Decoder::new(Cursor::new(png_bytes)); + let reader = decoder.read_info().unwrap(); + + assert_eq!((reader.info().width, reader.info().height), (3, 2)); + } + + // ── PNG memory parity ───────────────────────────────────────────────────── + + #[test] + fn png_round_trip_with_core_memory() { + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "remember this".to_string(), + }]; + let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + + assert_eq!(parsed.memory, snapshot.memory); + } + + #[test] + fn png_round_trip_with_everything_memory() { + let record = minimal_record(); + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "remember this".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "private notes".to_string(), + }, + ]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + + assert_eq!(parsed.memory, snapshot.memory); + } + + #[test] + fn png_export_with_no_memory_succeeds() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert!(encode_snapshot_png(&snapshot, None).is_ok()); + } + + #[test] + fn png_export_rejects_none_level_with_nonempty_entries() { + // Inconsistent state: level == None but entries is non-empty. + // The encoder must reject this to prevent a memory-leak bypass. + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked memory".to_string(), + }]; + // Build with entries, then override level to None in the struct. + let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + snapshot.memory.level = MemoryLevel::None; // force inconsistency + let result = encode_snapshot_png(&snapshot, None); + assert!( + result.is_err(), + "PNG encoder must reject level=None with non-empty entries" + ); + assert!( + result + .unwrap_err() + .contains("memory.level 'none' and non-empty memory entries"), + "Error must explain the malformed memory state" + ); + } + + // ── Secret exclusion tests ──────────────────────────────────────────────── + // + // These tests assert that every field in the exclusion list is absent from + // the serialized snapshot. We serialize to JSON and assert the key is NOT + // present. + + fn snapshot_json_string(record: &ManagedAgentRecord) -> String { + let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + String::from_utf8(bytes).unwrap() + } + + #[test] + fn secret_exclusion_private_key_nsec_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("nsec1secret"), + "nsec must not appear in snapshot" + ); + assert!( + !json.contains("privateKeyNsec") && !json.contains("private_key_nsec"), + "privateKeyNsec field must not appear in snapshot" + ); + } + + #[test] + fn secret_exclusion_auth_tag_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("auth-tag-secret"), + "auth_tag value must not appear in snapshot" + ); + assert!( + !json.contains("authTag") && !json.contains("auth_tag"), + "authTag field must not appear in snapshot" + ); + } + + #[test] + fn secret_exclusion_env_vars_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("API_KEY") && !json.contains("secret123"), + "env_vars content must not appear in snapshot" + ); + assert!( + !json.contains("envVars") && !json.contains("env_vars"), + "envVars field must not appear in snapshot" + ); + } + + #[test] + fn secret_exclusion_relay_url_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("wss://relay.example.com"), + "relay_url value must not appear in snapshot" + ); + assert!( + !json.contains("relayUrl") && !json.contains("relay_url"), + "relayUrl field must not appear in snapshot" + ); + } + + #[test] + fn snapshot_omits_removed_mcp_toolsets_config() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"), + "removed MCP toolsets config must not re-enter snapshots" + ); + } + + #[test] + fn secret_exclusion_machine_commands_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + // acp_command / agent_command / agent_command_override / agent_args / mcp_command + assert!( + !json.contains("/usr/local/bin/acp"), + "acp_command path must not appear" + ); + assert!( + !json.contains("acpCommand") && !json.contains("acp_command"), + "acpCommand field must not appear" + ); + assert!( + !json.contains("agentCommand") && !json.contains("agent_command"), + "agentCommand field must not appear" + ); + assert!( + !json.contains("mcpCommand") && !json.contains("mcp_command"), + "mcpCommand field must not appear" + ); + } + + #[test] + fn secret_exclusion_runtime_state_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("runtimePid") && !json.contains("runtime_pid"), + "runtimePid must not appear" + ); + assert!( + !json.contains("backendAgentId") && !json.contains("backend_agent_id"), + "backendAgentId must not appear" + ); + assert!( + !json.contains("SENTINEL_BACKEND_AGENT_ID"), + "backendAgentId value must not appear" + ); + assert!( + !json.contains("providerBinaryPath") && !json.contains("provider_binary_path"), + "providerBinaryPath must not appear" + ); + assert!( + !json.contains("SENTINEL_PROVIDER_BINARY"), + "providerBinaryPath value must not appear" + ); + assert!( + !json.contains("lastStartedAt") && !json.contains("last_started_at"), + "lastStartedAt must not appear" + ); + assert!( + !json.contains("lastExitCode") && !json.contains("last_exit_code"), + "lastExitCode must not appear" + ); + // backend blob — neither the type tag nor provider secret must leak. + assert!( + !json.contains("\"backend\"") && !json.contains("backend"), + "backend field must not appear" + ); + assert!( + !json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"), + "backend config values must not appear" + ); + // last_error / last_error_code + assert!( + !json.contains("lastError") && !json.contains("last_error"), + "lastError must not appear" + ); + assert!( + !json.contains("SENTINEL_LAST_ERROR"), + "lastError value must not appear" + ); + assert!( + !json.contains("lastErrorCode") && !json.contains("last_error_code"), + "lastErrorCode must not appear" + ); + } + + #[test] + fn secret_exclusion_lineage_ids_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("team-id-123"), + "source_team value must not appear" + ); + assert!( + !json.contains("sourceTeam") && !json.contains("source_team"), + "sourceTeam field must not appear" + ); + assert!( + !json.contains("sourceTeamPersonaSlug"), + "sourceTeamPersonaSlug must not appear" + ); + assert!( + !json.contains("personaSourceVersion") && !json.contains("persona_source_version"), + "personaSourceVersion must not appear" + ); + // personaId + assert!( + !json.contains("personaId") && !json.contains("persona_id"), + "personaId field must not appear" + ); + assert!( + !json.contains("SENTINEL_PERSONA_ID"), + "personaId value must not appear" + ); + // teamId + assert!( + !json.contains("teamId") && !json.contains("team_id"), + "teamId field must not appear" + ); + assert!( + !json.contains("SENTINEL_TEAM_ID"), + "teamId value must not appear" + ); + // personaTeamDir + assert!( + !json.contains("personaTeamDir") && !json.contains("persona_team_dir"), + "personaTeamDir field must not appear" + ); + assert!( + !json.contains("SENTINEL_TEAM_DIR"), + "personaTeamDir value must not appear" + ); + // personaNameInTeam + assert!( + !json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"), + "personaNameInTeam field must not appear" + ); + assert!( + !json.contains("SENTINEL_NAME_IN_TEAM"), + "personaNameInTeam value must not appear" + ); + } + + // ── Definition field presence tests ────────────────────────────────────── + + #[test] + fn definition_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + + assert_eq!(snapshot.definition.name, "Test Agent Display"); + assert_eq!( + snapshot.definition.system_prompt.as_deref(), + Some("You are a test agent.") + ); + assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose")); + assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4")); + assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic")); + assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]); + // definition_respond_to maps to respond_to in the snapshot definition + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + // definition_respond_to_allowlist should be included + assert!(!snapshot.definition.respond_to_allowlist.is_empty()); + } + + #[test] + fn profile_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.profile.display_name, "Test Agent Display"); + // No bytes → should fall back to avatar_url + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); + assert!(snapshot.profile.avatar_data_url.is_none()); + } + + #[test] + fn avatar_inlined_when_under_size_limit() { + let record = minimal_record(); + let small_png = make_png_with_text("k", "v").unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png)); + assert!(snapshot.profile.avatar_data_url.is_some()); + assert!(snapshot.profile.avatar_url.is_none()); + } + + #[test] + fn avatar_url_fallback_when_over_size_limit() { + let mut record = minimal_record(); + record.avatar_url = Some("https://example.com/big.png".to_string()); + // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec. + let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1]; + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes)); + assert!(snapshot.profile.avatar_data_url.is_none()); + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/big.png") + ); + } + + // ── Format/version validation ───────────────────────────────────────────── + + #[test] + fn invalid_format_discriminator_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.format = "not-a-buzz-snapshot".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot format")); + } + + #[test] + fn unsupported_version_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.version = 99; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot version")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs index 1b9a2947fa..449197a3b3 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs @@ -65,12 +65,13 @@ mod tests { /// logic as read_config_file but without touching the filesystem. fn parse_settings(json: &str) -> RuntimeFileConfig { let val: serde_json::Value = serde_json::from_str(json).unwrap(); - let mut cfg = RuntimeFileConfig::default(); - cfg.model = json_string(&val, "model"); - cfg.thinking_effort = json_string(&val, "effortLevel"); let skip = &["model", "effortLevel"]; - cfg.extra = super::super::schema_walker::extract_config_fields(&val, skip); - cfg + RuntimeFileConfig { + model: json_string(&val, "model"), + thinking_effort: json_string(&val, "effortLevel"), + extra: super::super::schema_walker::extract_config_fields(&val, skip), + ..Default::default() + } } #[test] diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs index 5ef8677c64..c7c7135ccb 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs @@ -122,7 +122,7 @@ fn toml_string(table: &toml::Table, key: &str) -> Option { .map(str::to_string) } -fn codex_config_path() -> Option { +pub(crate) fn codex_config_path() -> Option { if let Ok(home) = std::env::var("CODEX_HOME") { return Some(std::path::PathBuf::from(home).join("config.toml")); } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs index 4f835d1775..fce54edc40 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -53,15 +53,21 @@ fn parse_goose_config(yaml_str: &str) -> Option { if let Some(ref ap) = active_provider { extra.insert("active_provider".to_string(), ap.clone()); } - if let Some(host) = yaml_string(&map, "DATABRICKS_HOST") - .or_else(|| nested.as_ref().and_then(|n| n.host.clone())) - { + // Flat DATABRICKS_HOST key — always canonicalize to the literal env-key name + // so `file_key_present("DATABRICKS_HOST")` works regardless of active_provider. + // This is Will's typical config: flat key, no explicit active_provider. + if let Some(flat_host) = yaml_string(&map, "DATABRICKS_HOST") { + extra.insert("DATABRICKS_HOST".to_string(), flat_host); + } else if let Some(nested_host) = nested.as_ref().and_then(|n| n.host.clone()) { + // Nested providers..host — canonicalize to DATABRICKS_HOST when + // the active provider is a databricks variant; otherwise store verbatim + // (future provider types may have a different canonical key). let host_key = match active_provider.as_deref() { Some("databricks_v2") | Some("databricks") => "DATABRICKS_HOST".to_string(), Some(p) => format!("{p}.host"), None => "provider.host".to_string(), }; - extra.insert(host_key, host); + extra.insert(host_key, nested_host); } Some(RuntimeFileConfig { @@ -148,7 +154,7 @@ fn mapping_string(map: &serde_yaml::Mapping, key: &str) -> Option { .map(str::to_string) } -fn goose_config_path() -> Option { +pub(crate) fn goose_config_path() -> Option { if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") { return Some(PathBuf::from(root).join("config").join("config.yaml")); } @@ -267,6 +273,31 @@ GOOSE_TELEMETRY_ENABLED: false "#; let cfg = parse_goose_config(yaml).unwrap(); assert_eq!(cfg.provider.as_deref(), Some("databricks")); + // The flat DATABRICKS_HOST key must be canonical in extra so that + // `file_key_present("DATABRICKS_HOST")` returns true. This is Will's + // typical config — flat key, no active_provider. + assert_eq!( + cfg.extra.get("DATABRICKS_HOST").map(|s| s.as_str()), + Some("https://block-lakehouse-production.cloud.databricks.com/"), + "flat DATABRICKS_HOST must be stored as 'DATABRICKS_HOST' in extra" + ); + } + + #[test] + fn goose_provider_databricks_flat_host_no_active_provider() { + // GOOSE_PROVIDER=databricks + flat DATABRICKS_HOST, no active_provider. + // extra["DATABRICKS_HOST"] must be canonical. + let yaml = r#" +GOOSE_PROVIDER: databricks +DATABRICKS_HOST: https://dbc.example.com +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("databricks")); + assert_eq!( + cfg.extra.get("DATABRICKS_HOST").map(|s| s.as_str()), + Some("https://dbc.example.com"), + "flat DATABRICKS_HOST must be stored as 'DATABRICKS_HOST' even with GOOSE_PROVIDER set" + ); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index 654ae4857d..f8b045fc72 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -7,3 +7,12 @@ mod schema_walker; pub(crate) mod types; pub(crate) use types::*; + +/// Read the goose harness config file (`~/.config/goose/config.yaml`). +/// +/// Used by readiness evaluation to silence requirements that are already +/// satisfied in the file config layer — the harness reads this file at startup +/// so env vars we would otherwise require are not needed from Buzz. +pub(crate) fn read_goose_file_config() -> Option { + goose::read_config_file() +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 1c23bc6e31..372d2cfde1 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -167,6 +167,8 @@ pub(crate) fn read_config_surface( let config_file_path = runtime_meta .and_then(|m| m.config_file_path) .map(resolve_tilde); + let mcp_config_file_path = runtime_meta.and_then(mcp_config_file_path_for_runtime); + let extensions = file_config.extensions.clone(); let sources = ConfigSourceReport { acp_native: if supports_acp_native { @@ -197,6 +199,7 @@ pub(crate) fn read_config_surface( ConfigTierStatus::NotApplicable }, config_file_path, + mcp_config_file_path, }; RuntimeConfigSurface { @@ -205,10 +208,25 @@ pub(crate) fn read_config_surface( is_pre_spawn, normalized, advanced, + extensions, sources, } } +fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option { + match runtime.id { + "goose" => { + super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) + } + "claude" => Some(resolve_tilde("~/.claude.json")), + "codex" => { + super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) + } + _ => None, + } +} + +#[allow(clippy::too_many_arguments)] fn build_model_field( record_model: &Option, file_model: &Option, @@ -360,7 +378,11 @@ fn build_provider_field( (record_provider.as_deref(), ConfigOrigin::BuzzExplicit), (file_provider.as_deref(), ConfigOrigin::ConfigFile), ]; - let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers)?; + let (value, origin, overridden_value, overridden_origin) = match resolve_with_override(tiers) { + Some(resolved) => resolved, + None if is_required => (None, ConfigOrigin::EnvVar, None, None), + None => return None, + }; let write_via = if let Some(env_key) = provider_env_var { ConfigWriteMechanism::RespawnWithEnvVar { @@ -512,18 +534,19 @@ fn build_system_prompt_field( }) } -/// Picks the first `Some` value from `tiers` (highest-precedence first) and -/// returns `(value, origin, overridden_value, overridden_origin)` where the -/// overridden pair is the next `Some` tier after the winner. Returns `None` -/// when no tier has a value. -fn resolve_with_override( - tiers: &[(Option<&str>, ConfigOrigin)], -) -> Option<( +/// `(value, origin, overridden_value, overridden_origin)` — the resolved +/// winner plus the next `Some` tier it shadows, if any. +type ResolvedOverride = ( Option, ConfigOrigin, Option, Option, -)> { +); + +/// Picks the first `Some` value from `tiers` (highest-precedence first); +/// the overridden pair is the next `Some` tier after the winner. Returns +/// `None` when no tier has a value. +fn resolve_with_override(tiers: &[(Option<&str>, ConfigOrigin)]) -> Option { let winner_idx = tiers.iter().position(|(v, _)| v.is_some())?; let (value, origin) = &tiers[winner_idx]; let value = value.map(str::to_string); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index b35623d6a5..f5fc7c3899 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -2,12 +2,31 @@ //! `reader.rs` stays under the 1000-line budget; `#[path]`-included from //! there). -use std::collections::BTreeMap; +use std::{collections::BTreeMap, path::Path, sync::Mutex}; use super::*; use crate::managed_agents::discovery::KnownAcpRuntime; use crate::managed_agents::types::ManagedAgentRecord; +static GOOSE_PATH_ROOT_LOCK: Mutex<()> = Mutex::new(()); + +fn with_goose_path_root(value: Option<&str>, body: impl FnOnce() -> T) -> T { + let _guard = GOOSE_PATH_ROOT_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let prior = std::env::var_os("GOOSE_PATH_ROOT"); + match value { + Some(value) => std::env::set_var("GOOSE_PATH_ROOT", value), + None => std::env::remove_var("GOOSE_PATH_ROOT"), + } + let output = body(); + match prior { + Some(value) => std::env::set_var("GOOSE_PATH_ROOT", value), + None => std::env::remove_var("GOOSE_PATH_ROOT"), + } + output +} + fn test_runtime() -> &'static KnownAcpRuntime { &KnownAcpRuntime { id: "goose", @@ -19,6 +38,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { mcp_hooks: false, underlying_cli: None, cli_install_commands: &[], + cli_install_commands_windows: &[], adapter_install_commands: &[], install_instructions_url: "", cli_install_hint: "", @@ -36,6 +56,8 @@ fn test_runtime() -> &'static KnownAcpRuntime { max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, } } @@ -58,13 +80,14 @@ fn test_record() -> ManagedAgentRecord { parallelism: 1, system_prompt: None, model: None, - mcp_toolsets: None, env_vars: BTreeMap::new(), start_on_app_launch: false, + auto_restart_on_config_change: true, runtime_pid: None, backend: crate::managed_agents::types::BackendKind::Local, backend_agent_id: None, provider_binary_path: None, + team_id: None, persona_team_dir: None, persona_name_in_team: None, created_at: "".to_string(), @@ -73,8 +96,20 @@ fn test_record() -> ManagedAgentRecord { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: crate::managed_agents::types::RespondTo::OwnerOnly, respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, relay_mesh: None, agent_command_override: None, persona_source_version: None, @@ -97,6 +132,68 @@ fn pre_spawn_surface_reports_pending_acp_tiers() { assert_eq!(surface.sources.env_vars, ConfigTierStatus::Available); } +#[test] +fn surface_reports_mcp_specific_config_path() { + let record = test_record(); + let runtime = test_runtime(); + let surface = with_goose_path_root(None, || { + read_config_surface(&record, Some(runtime), None, None) + }); + + let path = surface + .sources + .mcp_config_file_path + .expect("mcp config path"); + let expected_suffix = Path::new(".config").join("goose").join("config.yaml"); + assert!( + Path::new(&path).ends_with(&expected_suffix), + "unexpected MCP config path: {path}" + ); +} + +#[test] +fn goose_mcp_config_path_follows_path_root_override() { + let record = test_record(); + let runtime = test_runtime(); + let surface = with_goose_path_root(Some("/tmp/buzz-goose-root"), || { + read_config_surface(&record, Some(runtime), None, None) + }); + + let expected_path = Path::new("/tmp/buzz-goose-root") + .join("config") + .join("config.yaml"); + assert_eq!( + surface + .sources + .mcp_config_file_path + .as_deref() + .map(Path::new), + Some(expected_path.as_path()) + ); +} + +#[test] +fn claude_surface_uses_mcp_config_path_not_settings_path() { + let record = test_record(); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + let surface = read_config_surface(&record, Some(runtime), None, None); + + assert!(surface + .sources + .config_file_path + .as_deref() + .is_some_and(|path| path.ends_with(".claude/settings.json"))); + assert!(surface + .sources + .mcp_config_file_path + .as_deref() + .is_some_and(|path| path.ends_with(".claude.json"))); +} + #[test] fn record_model_overrides_file_model() { let mut record = test_record(); @@ -516,6 +613,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { mcp_hooks: false, underlying_cli: None, cli_install_commands: &[], + cli_install_commands_windows: &[], adapter_install_commands: &[], install_instructions_url: "", cli_install_hint: "", @@ -533,6 +631,8 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, } } @@ -659,3 +759,18 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() { "thinking_effort must not appear in advanced when normalized" ); } + +#[test] +fn missing_required_provider_still_returns_dropdown_field() { + let provider = build_provider_field(&None, &None, Some("GOOSE_PROVIDER"), false, true) + .expect("required provider field should be surfaced even when empty"); + + assert_eq!(provider.value, None); + assert_eq!(provider.origin, ConfigOrigin::EnvVar); + assert!(provider.is_required); +} + +#[test] +fn missing_optional_provider_stays_hidden() { + assert!(build_provider_field(&None, &None, Some("GOOSE_PROVIDER"), false, false).is_none()); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index c201141df3..15ccb718e7 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -21,6 +21,11 @@ pub enum ConfigOrigin { /// resolved before calling the reader, then the surface is post-processed to /// re-tag injected fields from `BuzzExplicit` to `PersonaDefault`. PersonaDefault, + /// Value inherited from global agent configuration defaults. + /// The lowest user-settable layer — active when neither the agent record nor + /// the linked persona specifies a value. Re-tagged from `BuzzExplicit` by the + /// `resolve_config_surface` call site, analogously to `PersonaDefault`. + GlobalDefault, /// Live runtime model override applied via the ModelPicker (Phase 3). /// The ACP session's current model diverges from the persona model because /// the user picked a different model on the running instance. Runtime-only — @@ -118,6 +123,7 @@ pub struct ConfigSourceReport { pub env_vars: ConfigTierStatus, pub config_file: ConfigTierStatus, pub config_file_path: Option, + pub mcp_config_file_path: Option, } /// Full config surface returned to the frontend. @@ -129,6 +135,7 @@ pub struct RuntimeConfigSurface { pub is_pre_spawn: bool, pub normalized: NormalizedConfig, pub advanced: Vec, + pub extensions: Vec, pub sources: ConfigSourceReport, } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 14b91eab64..16696b5f9c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1,8 +1,10 @@ +use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::{Duration, Instant}; use crate::managed_agents::{ - AcpAvailabilityStatus, AcpRuntimeCatalogEntry, CommandAvailabilityInfo, + AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, }; pub(crate) struct KnownAcpRuntime { @@ -20,6 +22,10 @@ pub(crate) struct KnownAcpRuntime { pub underlying_cli: Option<&'static str>, /// Shell commands to install the runtime CLI itself (run sequentially). pub cli_install_commands: &'static [&'static str], + /// Windows-specific CLI install commands (e.g. PowerShell installers). + /// When non-empty on Windows, these are used instead of `cli_install_commands`. + #[allow(dead_code)] // read only on Windows via cli_install_commands_for_os() + pub cli_install_commands_windows: &'static [&'static str], /// Shell commands to install the ACP adapter (run sequentially, after CLI). pub adapter_install_commands: &'static [&'static str], /// Link to docs/repo for manual instructions. @@ -56,6 +62,29 @@ pub(crate) struct KnownAcpRuntime { /// Used by the config bridge to mark fields as required in the UI. /// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider"). pub required_normalized_fields: &'static [&'static str], + /// Human-readable hint shown in Doctor when the runtime is available but not + /// authenticated. `None` for runtimes that have no login step (goose, buzz-agent). + pub login_hint: Option<&'static str>, + /// CLI args for probing authentication status. `args[0]` is the binary name; + /// the remainder are the subcommand. `None` for runtimes with no login step. + pub auth_probe_args: Option<&'static [&'static str]>, +} + +impl KnownAcpRuntime { + /// Return the CLI install commands for the current platform. + /// + /// On Windows, returns `cli_install_commands_windows` when non-empty, + /// falling back to the default `cli_install_commands`. On other platforms + /// always returns `cli_install_commands`. + pub fn cli_install_commands_for_os(&self) -> &[&str] { + #[cfg(windows)] + { + if !self.cli_install_commands_windows.is_empty() { + return self.cli_install_commands_windows; + } + } + self.cli_install_commands + } } const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -82,6 +111,22 @@ fn common_binary_paths() -> &'static [PathBuf] { home.join(".asdf/shims"), ]); } + // Windows well-known dirs for npm global shims and standalone installer targets. + #[cfg(windows)] + { + if let Some(appdata) = std::env::var_os("APPDATA") { + paths.push(PathBuf::from(appdata).join("npm")); + } + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + paths.push( + PathBuf::from(local) + .join("Programs") + .join("OpenAI") + .join("Codex") + .join("bin"), + ); + } + } paths }) } @@ -97,6 +142,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("goose"), cli_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], + cli_install_commands_windows: &[], // goose install script is already Windows-aware adapter_install_commands: &[], install_instructions_url: "https://block.github.io/goose/", cli_install_hint: "Install Goose via the official install script.", @@ -114,6 +160,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, }, KnownAcpRuntime { id: "claude", @@ -125,6 +173,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("claude"), cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""], adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", cli_install_hint: "Install the Claude Code CLI via the official install script.", @@ -142,6 +191,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_tokens_env_var: None, context_limit_env_var: None, required_normalized_fields: &[], + login_hint: Some("Run the Claude CLI to complete authentication."), + auth_probe_args: Some(&["claude", "auth", "status"]), }, KnownAcpRuntime { id: "codex", @@ -153,8 +204,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("codex"), cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], - adapter_install_commands: &["npm install -g @zed-industries/codex-acp"], - install_instructions_url: "https://github.com/zed-industries/codex-acp", + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""], + adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], + install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", cli_install_hint: "Install the Codex CLI via the official install script.", adapter_install_hint: "Install the Codex ACP adapter via npm.", skill_dir: Some(".codex/skills"), @@ -170,6 +222,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_tokens_env_var: None, context_limit_env_var: None, required_normalized_fields: &[], + login_hint: Some("Run `codex login` to authenticate."), + // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. + auth_probe_args: Some(&["codex", "login", "status"]), }, KnownAcpRuntime { id: "buzz-agent", @@ -181,6 +236,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: true, underlying_cli: None, cli_install_commands: &[], + cli_install_commands_windows: &[], adapter_install_commands: &[], install_instructions_url: "https://github.com/block/buzz", cli_install_hint: "Ships with the Buzz desktop app.", @@ -198,6 +254,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, }, ]; @@ -271,9 +329,9 @@ pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRunti } /// The agent command a freshly-created agent defaults to when the create -/// request supplies none. Resolves the bundled `buzz-agent` from the catalog — -/// the same shape `mesh_llm::preset` uses — so the default can't drift from the -/// provider definition. Falls back to the id if the catalog entry is missing. +/// request supplies none. Resolves the bundled `buzz-agent` from the catalog so +/// the default cannot drift from the provider definition. Falls back to the id +/// if the catalog entry is missing. /// /// The previous default was the bare global `goose`, which is not on PATH on a /// stock Windows install: every worker failed with `program not found`. The @@ -285,6 +343,40 @@ pub fn default_agent_command() -> String { .to_string() } +/// Record-first harness resolution (unified agent model, Phase 1A). +/// +/// Resolution order: +/// 1. explicit override (non-empty) — a deliberate per-instance pin; +/// 2. the record's own `runtime` id mapped to its primary command — +/// records materialize their runtime at create/migration time; +/// 3. legacy fallback: the linked persona's `runtime` (records created +/// before the unified model carry `persona_id` but no `runtime`); +/// 4. `default_agent_command()`. +pub fn record_agent_command( + record: &crate::managed_agents::types::ManagedAgentRecord, + personas: &[crate::managed_agents::types::AgentDefinition], +) -> String { + if let Some(pin) = record + .agent_command_override + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return pin.to_string(); + } + + if let Some(command) = record + .runtime + .as_deref() + .and_then(known_acp_runtime_exact) + .and_then(|r| r.commands.first().copied()) + { + return command.to_string(); + } + + effective_agent_command(record.persona_id.as_deref(), personas, None) +} + /// Resolve the agent command (harness) for a spawn/deploy/summary. The linked /// persona wins so persona harness edits propagate on the next spawn. An /// explicit per-instance override (`agent_command_override`) takes precedence. @@ -295,7 +387,7 @@ pub fn default_agent_command() -> String { /// 3. `default_agent_command()` — no persona/runtime, or persona deleted. pub fn effective_agent_command( persona_id: Option<&str>, - personas: &[crate::managed_agents::types::PersonaRecord], + personas: &[crate::managed_agents::types::AgentDefinition], agent_command_override: Option<&str>, ) -> String { if let Some(pin) = agent_command_override @@ -314,84 +406,8 @@ pub fn effective_agent_command( .unwrap_or_else(default_agent_command) } -/// Decide whether a user-picked harness command is an explicit per-instance -/// pin or merely the persona's own runtime restated. Returns the override to -/// persist: `Some(picked)` when it diverges from the persona, `None` when it -/// inherits. -/// -/// Comparison is by RUNTIME IDENTITY, not raw string: a persona on the `claude` -/// runtime resolves to `claude-agent-acp`, but a client with only the -/// `claude-code-acp` adapter installed sends that command instead. Both map to -/// the same `claude` runtime, so neither is a real divergence — string equality -/// would wrongly bake a pin. An unknown/custom command (no matching runtime) -/// only inherits when it exactly equals the persona command. -pub fn divergent_agent_command_override( - persona_id: Option<&str>, - personas: &[crate::managed_agents::types::PersonaRecord], - picked_command: Option<&str>, -) -> Option { - let picked = picked_command - .map(str::trim) - .filter(|value| !value.is_empty())?; - let persona_command = effective_agent_command(persona_id, personas, None); - let same_runtime = match ( - known_acp_runtime(picked), - known_acp_runtime(&persona_command), - ) { - (Some(a), Some(b)) => std::ptr::eq(a, b), - _ => picked == persona_command, - }; - if same_runtime { - None - } else { - Some(picked.to_string()) - } -} - -/// Decide the `agent_command_override` to persist at AGENT CREATE time. -/// -/// A persona-backed create receives its harness command from -/// `resolvePersonaRuntime` (frontend), which produces a divergent command in two -/// distinct cases that the backend MUST tell apart: -/// -/// - DELIBERATE OVERRIDE (`harness_override` true): the user explicitly picked a -/// runtime command in UI that exposes a runtime selector. This is a real pin -/// and is preserved when it differs from the command inheritance would spawn, -/// including installed aliases such as `claude-code-acp`. -/// - MISSING-RUNTIME FALLBACK (`harness_override` false): the persona's runtime -/// isn't installed locally, so `resolvePersonaRuntime` substitutes a fallback -/// default. This is NOT a pin — baking it would freeze the agent on the fallback -/// harness even after the persona's runtime is installed and the persona is -/// re-edited, the exact bug this resolver chain exists to prevent. Stores `None` -/// so the persona stays authoritative. -/// -/// `isOverridden` from `resolvePersonaRuntime` cannot distinguish these — it is -/// `true` for BOTH — so the caller must thread the explicit user-intent bit. -/// -/// Persona-less creates (`persona_id` is `None`, e.g. the standalone -/// CreateAgentDialog) have no persona to inherit, so the picked command is always a -/// real pin and is preserved via `divergent_agent_command_override` regardless of -/// `harness_override`. -pub fn create_time_agent_command_override( - persona_id: Option<&str>, - personas: &[crate::managed_agents::types::PersonaRecord], - picked_command: Option<&str>, - harness_override: bool, -) -> Option { - if persona_id.is_some() && !harness_override { - return None; - } - - if persona_id.is_some() && harness_override { - let picked = picked_command - .map(str::trim) - .filter(|value| !value.is_empty())?; - let inherited_command = effective_agent_command(persona_id, personas, None); - return (picked != inherited_command).then(|| picked.to_string()); - } - - divergent_agent_command_override(persona_id, personas, picked_command) -} +mod overrides; +pub use overrides::{apply_agent_command_update, create_time_agent_command_override}; fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { @@ -425,32 +441,32 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec [PathBuf; 2] { + if cfg!(debug_assertions) { + // `just dev` builds fresh debug sidecars; never prefer stale release output. + [root.join("target/debug"), root.join("target/release")] + } else { + [root.join("target/release"), root.join("target/debug")] + } +} + fn command_search_dirs() -> Vec { - let mut dirs = vec![ - workspace_root_dir().join("target/release"), - workspace_root_dir().join("target/debug"), - ]; - + let mut dirs = profile_target_dirs(&workspace_root_dir()).to_vec(); if let Ok(current_dir) = std::env::current_dir() { - dirs.push(current_dir.join("target/release")); - dirs.push(current_dir.join("target/debug")); + dirs.extend(profile_target_dirs(¤t_dir)); } - if let Ok(exe_path) = std::env::current_exe() { - if let Some(parent) = exe_path.parent() { - dirs.push(parent.to_path_buf()); + dirs.extend( + std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)), + ); + dirs.into_iter().fold(Vec::new(), |mut unique, dir| { + if !unique.contains(&dir) { + unique.push(dir); } - } - - let mut unique = Vec::new(); - for dir in dirs { - if unique.iter().any(|candidate: &PathBuf| candidate == &dir) { - continue; - } - unique.push(dir); - } - - unique + unique + }) } fn is_executable_file(path: &Path) -> bool { @@ -523,6 +539,93 @@ pub fn resolve_command(command: &str) -> Option { pub fn clear_resolve_cache() { let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner()); guard.clear(); + // Also invalidate the adapter-availability cache so a freshly-installed + // adapter is reflected the next time the summary builder checks the badge. + clear_adapter_availability_cache(); +} + +// ── Adapter availability cache (Phase-2 badge fallback) ───────────────────── +// +// `build_managed_agent_summary` needs to compare the spawn-time adapter +// availability against the *current* availability without triggering a live +// `probe_codex_acp_major_version` subprocess on every poll cycle. This cache +// stores the last availability status of the codex-acp binary at its resolved +// path. It is warmed by `discover_acp_runtimes` (which already probes), so +// the badge path reads warm data, and is invalidated by `clear_resolve_cache` +// (called on every Doctor install and every `discover_acp_providers` call). + +fn adapter_availability_cache() -> &'static std::sync::Mutex> { + use std::sync::{Mutex, OnceLock}; + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(None)) +} + +fn clear_adapter_availability_cache() { + if let Ok(mut guard) = adapter_availability_cache().lock() { + *guard = None; + } +} + +/// Cache the current codex-acp adapter availability status. +/// +/// Called by `discover_acp_runtimes` after it probes the codex adapter so the +/// badge path has a warm value without re-probing. +pub(crate) fn cache_adapter_availability(status: AcpAvailabilityStatus) { + if let Ok(mut guard) = adapter_availability_cache().lock() { + *guard = Some(status); + } +} + +/// Return the most recently cached codex-acp adapter availability, or +/// `None` if no discovery has run yet. +/// +/// This is a **read from cache only** — it never spawns a subprocess. The +/// value is populated by `discover_acp_runtimes` and invalidated by +/// `clear_resolve_cache`. When the cache is cold, returning `None` defers +/// the drift check until discovery has produced a real value, preventing +/// a fabricated `AdapterMissing` stamp from triggering a false restart badge +/// on a newly restarted process. +pub(crate) fn adapter_availability_cached() -> Option { + adapter_availability_cache() + .lock() + .ok() + .and_then(|g| g.clone()) +} + +/// Pure predicate: does the stamped adapter availability differ from the +/// current cached availability? +/// +/// Returns `false` whenever either side is `None` (unknown) — "no data" is +/// not evidence of drift. This is extracted for unit testing without global +/// state and used by `build_managed_agent_summary`. +pub(crate) fn availability_drift( + stamped: Option<&AcpAvailabilityStatus>, + current: Option, +) -> bool { + match (stamped, current) { + (Some(s), Some(c)) => *s != c, + _ => false, + } +} + +/// Return all candidate basenames for `command` on the current platform. +/// +/// Always includes `executable_basename(command)` (appends `.exe` on Windows). +/// On Windows also includes `.cmd` and `.bat` variants so npm-generated shims +/// (e.g. `codex-acp.cmd` in `%APPDATA%\npm`) are discoverable. +fn command_basenames(command: &str) -> Vec { + let candidates = vec![executable_basename(command)]; + #[cfg(windows)] + { + let mut candidates = candidates; + if !command.contains('.') { + candidates.push(format!("{command}.cmd")); + candidates.push(format!("{command}.bat")); + } + return candidates; + } + #[allow(unreachable_code)] + candidates } fn resolve_command_uncached(command: &str) -> Option { @@ -541,13 +644,43 @@ fn resolve_command_uncached(command: &str) -> Option { } } + // On Windows, also scan PATH for .cmd/.bat shims (npm globals). + #[cfg(windows)] + { + for basename in command_basenames(command).iter().skip(1) { + for candidate in path_candidates_from_env_raw(basename) { + if candidate.is_file() { + return Some(candidate); + } + } + } + } + if let Some(path) = find_via_login_shell(command) { return Some(path); } + let basenames = command_basenames(command); for dir in common_binary_paths() { - let candidate = dir.join(executable_basename(command)); - if is_executable_file(&candidate) { - return Some(candidate); + for basename in &basenames { + let candidate = dir.join(basename); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + } + + // Check nvm's default Node.js bin directory — nvm initializes via + // ~/.zshrc (interactive) which is not loaded by a login shell, so + // `node`, `npm`, and npm-global shims installed there are otherwise + // invisible. + if let Some(home) = dirs::home_dir() { + if let Some(nvm_bin) = find_nvm_default_bin(&home) { + for basename in &basenames { + let candidate = nvm_bin.join(basename); + if is_executable_file(&candidate) { + return Some(candidate); + } + } } } @@ -564,11 +697,40 @@ fn path_candidates_from_env(command: &str) -> Vec { .unwrap_or_default() } -/// Run a command in a login shell (tries zsh then bash). +/// Like `path_candidates_from_env` but joins `basename` as-is (no `.exe` suffix). +/// Used for `.cmd`/`.bat` shim resolution on Windows. +#[cfg(windows)] +fn path_candidates_from_env_raw(basename: &str) -> Vec { + std::env::var_os("PATH") + .map(|paths| { + std::env::split_paths(&paths) + .map(|dir| dir.join(basename)) + .collect::>() + }) + .unwrap_or_default() +} + +/// Collect login shell candidates for the current platform. +/// +/// On Unix: `/bin/zsh`, `/bin/bash` (the historical defaults). +/// On Windows: Git Bash via `resolve_bash_path` — skips `BUZZ_SHELL` because +/// login-shell callers use bash-only `-l -c` syntax. +fn login_shell_candidates() -> Vec { + #[cfg(not(windows))] + { + vec![PathBuf::from("/bin/zsh"), PathBuf::from("/bin/bash")] + } + #[cfg(windows)] + { + super::git_bash::resolve_bash_path().into_iter().collect() + } +} + +/// Run a command in a login shell (tries zsh then bash on Unix, Git Bash on Windows). /// Returns trimmed stdout if the command succeeds with non-empty output. fn run_in_login_shell(args: &[&str]) -> Option { - for shell in ["/bin/zsh", "/bin/bash"] { - let Ok(output) = Command::new(shell).args(args).output() else { + for shell in login_shell_candidates() { + let Ok(output) = Command::new(&shell).args(args).output() else { continue; }; if !output.status.success() { @@ -589,24 +751,299 @@ fn find_via_login_shell(command: &str) -> Option { (path.is_absolute() && is_executable_file(&path)).then_some(path) } -/// Return the user's full PATH from a login shell. -/// Cached via OnceLock so we only spawn one shell per app lifetime. -pub fn login_shell_path() -> Option { - use std::sync::OnceLock; - static CACHED: OnceLock> = OnceLock::new(); - CACHED - .get_or_init(|| { - let stdout = run_in_login_shell(&["-l", "-c", "echo $PATH"])?; - let last_line = stdout.lines().rfind(|l| !l.trim().is_empty())?; - Some(last_line.trim().to_string()) - }) - .clone() +/// Three-state backing store for the login-shell PATH cache. +#[derive(Clone)] +enum LoginShellPath { + /// Cache has never been populated; the next call will spawn a login shell. + Uninit, + /// A login shell was invoked; the inner value is the PATH it returned + /// (`None` when the shell produced no output). + Probed(Option), } -fn find_command(command: &str) -> Option { +fn path_cache() -> &'static std::sync::Mutex { + use std::sync::{Mutex, OnceLock}; + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(LoginShellPath::Uninit)) +} + +fn fetch_login_shell_path_inner() -> Option { + // On Windows, Git Bash's `echo $PATH` returns POSIX colon-delimited paths + // (`/mingw64/bin:/c/Users/...`) which poison native Windows children that + // split on `;`. login_shell_path() feeds agent_models, runtime, and + // cli_probe — all native processes. Return None so they inherit the real + // Windows PATH instead. + #[cfg(windows)] + { + return None; + } + + #[cfg(not(windows))] + { + let stdout = run_in_login_shell(&["-l", "-c", "echo $PATH"])?; + let last_line = stdout.lines().rfind(|l| !l.trim().is_empty())?; + Some(last_line.trim().to_string()) + } +} + +/// Return the user's full PATH from a login shell. +/// +/// The result is cached after the first call. Call [`refresh_login_shell_path`] +/// to invalidate the cache so the next call re-fetches — e.g. after the user +/// installs Node.js mid-session and clicks Retry. +/// +/// The lock is never held while the login shell spawns: we check for a cached +/// value, release the lock, run the shell, then re-lock to write. Two concurrent +/// callers may both run the shell (last-writer-wins is fine — both produce the +/// same result), but neither blocks a concurrent agent spawn on the Mutex. +pub fn login_shell_path() -> Option { + // Fast path: return cached result without spawning a shell. + { + let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + if let LoginShellPath::Probed(ref result) = *guard { + return result.clone(); + } + } + + // Slow path: spawn shell outside any lock. + let result = fetch_login_shell_path_inner(); + + // Write back; last-writer-wins is safe here. + { + let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + *guard = LoginShellPath::Probed(result.clone()); + } + + result +} + +/// Invalidate the login-shell PATH cache so the next [`login_shell_path`] call +/// re-fetches from a fresh login shell. +/// +/// Called before every install/retry operation and on Doctor Re-run so a +/// newly-installed tool becomes visible without restarting the app. +pub(crate) fn refresh_login_shell_path() { + let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + *guard = LoginShellPath::Uninit; +} + +#[cfg(test)] +fn is_login_shell_path_uninit() -> bool { + matches!( + *path_cache().lock().unwrap_or_else(|e| e.into_inner()), + LoginShellPath::Uninit + ) +} + +/// Return `true` when `tag` is a safe nvm alias/version tag that can be joined +/// onto a `PathBuf` without escaping the nvm root. +/// +/// nvm uses tags like `v22.1.0` or `lts/hydrogen`. We allow ASCII alphanumeric +/// plus `. - / _` and require that no path component is `..` and that the tag +/// does not start with `/` (which would replace the base in `PathBuf::join`). +fn is_safe_nvm_tag(tag: &str) -> bool { + if tag.is_empty() { + return false; + } + // An absolute path in the alias file would let PathBuf::join silently + // replace the nvm root with an attacker-controlled path. + if tag.starts_with('/') { + return false; + } + // Reject any .. component to prevent upward traversal. + for component in tag.split('/') { + if component == ".." { + return false; + } + } + // Allow only the characters nvm uses in real tag names. + tag.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '/' | '_')) +} + +/// Locate the `bin` directory for nvm's default Node.js version. +/// +/// Reads `~/.nvm/alias/default`; resolves at most one alias hop to handle +/// nvm alias chains; falls back to the highest-semver directory under +/// `~/.nvm/versions/node/`. Returns the `bin` subdirectory only when it exists. +/// +/// Cheap: at most two file reads or one `read_dir`. Never cached — computed +/// fresh per call so a mid-session `nvm install` is visible at the next spawn. +pub fn find_nvm_default_bin(home: &Path) -> Option { + let nvm_root = home.join(".nvm"); + let versions_root = nvm_root.join("versions").join("node"); + + // 1. Try alias/default, with at most one hop. + let default_alias = nvm_root.join("alias").join("default"); + if let Ok(content) = std::fs::read_to_string(&default_alias) { + let tag = content.trim().to_string(); + if is_safe_nvm_tag(&tag) { + let candidate = versions_root.join(&tag).join("bin"); + if candidate.is_dir() { + return Some(candidate); + } + // One alias hop: ~/.nvm/alias/ + let hop_file = nvm_root.join("alias").join(&tag); + if let Ok(hop_content) = std::fs::read_to_string(&hop_file) { + let hop_tag = hop_content.trim().to_string(); + if is_safe_nvm_tag(&hop_tag) { + let hop_candidate = versions_root.join(&hop_tag).join("bin"); + if hop_candidate.is_dir() { + return Some(hop_candidate); + } + } + } + } + } + + // 2. Fall back to highest-semver directory under ~/.nvm/versions/node/. + let entries = std::fs::read_dir(&versions_root).ok()?; + let best = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name(); + let s = name.to_string_lossy().into_owned(); + parse_semver_tag(&s).map(|v| (v, s)) + }) + .max_by(|(a, _), (b, _)| a.cmp(b)); + + let (_, tag) = best?; + let bin = versions_root.join(&tag).join("bin"); + bin.is_dir().then_some(bin) +} + +/// Parse a `vMAJ.MIN.PATCH` (or `vMAJ.MIN.PATCH-extra`) tag into a numeric +/// triple for semver comparison. +fn parse_semver_tag(s: &str) -> Option<(u64, u64, u64)> { + let s = s.strip_prefix('v')?; + let mut parts = s.splitn(3, '.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch_str = parts.next()?; + let patch = patch_str.split('-').next()?.parse::().ok()?; + Some((major, minor, patch)) +} + +pub(crate) fn find_command(command: &str) -> Option { resolve_command(command) } +/// Returns true when the runtime has at least one adapter install step that +/// is an npm global install. Used to determine whether Node.js is required. +fn runtime_needs_npm(runtime: &KnownAcpRuntime) -> bool { + runtime + .adapter_install_commands + .iter() + .any(|cmd| is_npm_global_install(cmd)) +} + +/// Returns `true` when `cmd` is an `npm install -g` invocation. +/// +/// Used by Doctor to determine whether Node.js is required before running an +/// install step, and by the npm EACCES preflight in the install command path. +pub(crate) fn is_npm_global_install(cmd: &str) -> bool { + let t = cmd.trim_start(); + t.starts_with("npm install -g ") || t.starts_with("npm i -g ") +} + +/// Run a CLI auth probe with a 10-second process-level timeout. +/// +/// Spawns the probe CLI as a child process. Stdout and stderr are drained on +/// background threads to prevent pipe-buffer deadlock. On timeout the child is +/// killed and `Unknown` is returned; no orphaned threads or processes are left +/// behind. Returns `Unknown` on timeout. +fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { + use crate::managed_agents::readiness::cli_probe; + + let augmented_path = cli_probe::augmented_path(); + + let mut command = std::process::Command::new(binary_path); + command.args(&probe_args[1..]); + if let Some(ref path) = augmented_path { + command.env("PATH", path); + } + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let mut child = match command.spawn() { + Ok(c) => c, + Err(_) => return AuthStatus::Unknown, + }; + + // Drain stdout/stderr on background threads to prevent pipe-buffer deadlock. + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let stdout_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut pipe) = stdout_pipe { + let _ = pipe.read_to_end(&mut buf); + } + }); + let stderr_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut pipe) = stderr_pipe { + let _ = pipe.read_to_end(&mut buf); + } + buf + }); + + // Save PID for kill-on-timeout before moving child into the wait thread. + let child_pid = child.id(); + let (tx, rx) = std::sync::mpsc::channel(); + let wait_thread = std::thread::spawn(move || { + let _ = tx.send(child.wait()); + }); + + // 10-second timeout for auth probes. + let deadline = Instant::now() + Duration::from_secs(10); + let exit_status = loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + #[cfg(unix)] + unsafe { + libc::kill(child_pid as i32, libc::SIGTERM); + } + #[cfg(not(unix))] + let _ = child_pid; + drop(rx); + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return AuthStatus::Unknown; + } + match rx.recv_timeout(Duration::from_millis(100).min(remaining)) { + Ok(Ok(status)) => break status, + Ok(Err(_)) => { + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return AuthStatus::Unknown; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return AuthStatus::Unknown; + } + } + }; + + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let stderr_bytes = stderr_thread.join().unwrap_or_default(); + + match cli_probe::classify_probe_output(&stderr_bytes, exit_status.success()) { + cli_probe::ProbeOutcome::LoggedIn => AuthStatus::LoggedIn, + cli_probe::ProbeOutcome::LoggedOut => AuthStatus::LoggedOut, + cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => AuthStatus::ConfigInvalid { + diagnostic: stderr_excerpt, + }, + } +} + pub fn command_availability(command: &str) -> CommandAvailabilityInfo { let resolved_path = resolve_command(command).map(|path| path.display().to_string()); CommandAvailabilityInfo { @@ -626,7 +1063,7 @@ pub fn missing_command_message(command: &str, role: &str) -> String { ) } -fn classify_runtime( +pub(crate) fn classify_runtime( adapter_result: Option<(&str, PathBuf)>, underlying_cli: Option<&str>, underlying_cli_found: bool, @@ -652,11 +1089,125 @@ fn classify_runtime( } } +/// Probe the major version of a `codex-acp` binary by running `--version`. +/// +/// The 1.x adapter (`@agentclientprotocol/codex-acp`) outputs +/// `@agentclientprotocol/codex-acp ..` on stdout and exits 0. +/// The old 0.16.x adapter (`@zed-industries/codex-acp`) is a Rust binary that does +/// not recognise `--version` and exits non-zero. +/// +/// Returns the major version on success, `None` on any failure (non-zero exit, +/// unparseable output, timeout, or missing binary). +/// +/// The probe is bounded by a 5-second deadline. The child is polled with +/// [`std::process::Child::try_wait`] (the repo's standard deadline pattern) and +/// killed if it does not exit in time. +/// +/// Stdout is redirected to a temporary file rather than a pipe, so forked +/// descendants cannot hold EOF open. Reads from a regular file return EOF at its +/// current write position regardless of inherited file descriptors, cross-platform. +pub(crate) fn probe_codex_acp_major_version(binary_path: &Path) -> Option { + probe_codex_acp_major_version_with_path( + binary_path, + crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), + ) +} +pub(crate) fn probe_codex_acp_major_version_with_path( + binary_path: &Path, + augmented_path: Option<&str>, +) -> Option { + use std::io::{Read as _, Seek as _, SeekFrom}; + use std::time::{Duration, Instant}; + const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); + + // A regular file returns EOF at its current size even when a descendant + // inherits its descriptor, bounding the post-exit read cross-platform. + let mut tmp = tempfile::tempfile().ok()?; + + let mut command = Command::new(binary_path); + command.arg("--version"); + if let Some(path) = augmented_path { + command.env("PATH", path); + } + let mut child = command + .stdout(tmp.try_clone().ok()?) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + + // Poll until the deadline rather than blocking on stdout EOF. + let deadline = Instant::now() + VERSION_PROBE_TIMEOUT; + let exit_status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + }; + + if !exit_status.success() { + return None; + } + + // Read at most 4 KiB from the regular file without blocking. + tmp.seek(SeekFrom::Start(0)).ok()?; + let mut buf = Vec::with_capacity(128); + let _ = (&mut tmp as &mut dyn std::io::Read) + .take(4096) + .read_to_end(&mut buf); + + let stdout = String::from_utf8_lossy(&buf); + // Output format: " .." + let version_str = stdout.split_whitespace().last()?; + let major_str = version_str.split('.').next()?; + major_str.parse::().ok() +} + +/// Classifies a resolved codex-acp binary path as [`AcpAvailabilityStatus::Available`] +/// or [`AcpAvailabilityStatus::AdapterOutdated`]. +/// +/// The 0.16.x adapter (`@zed-industries/codex-acp`) does not recognise `--version` +/// and exits non-zero — that probe failure yields `AdapterOutdated`. The 1.x adapter +/// (`@agentclientprotocol/codex-acp`) prints its version and exits 0; major ≥ 1 +/// yields `Available`. +/// +/// Used by `discover_acp_runtimes`, `cli_login_requirements`, and +/// `install_acp_runtime_blocking` so the version-gate logic is not duplicated. +pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus { + match probe_codex_acp_major_version(path) { + Some(major) if major >= 1 => AcpAvailabilityStatus::Available, + _ => AcpAvailabilityStatus::AdapterOutdated, + } +} + +/// Returns `true` when the codex-acp binary at `path` is outdated (major version < 1) +/// or cannot be probed. Thin wrapper around [`codex_adapter_availability`]. +pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { + codex_adapter_availability(path) == AcpAvailabilityStatus::AdapterOutdated +} + +/// Intermediate struct built before the (potentially slow) auth probe phase. +struct PartialEntry { + runtime: &'static KnownAcpRuntime, + entry: AcpRuntimeCatalogEntry, +} + pub fn discover_acp_runtimes() -> Vec { - KNOWN_ACP_RUNTIMES + // Phase 1: build all entries (fast — no probes yet). + let mut partials: Vec = KNOWN_ACP_RUNTIMES .iter() .map(|runtime| { - // Try to find the ACP adapter binary. let adapter_result = runtime .commands .iter() @@ -666,9 +1217,28 @@ pub fn discover_acp_runtimes() -> Vec { .underlying_cli .map(|cli| find_command(cli).is_some()) .unwrap_or(false); - let (availability, command, binary_path) = + let (mut availability, command, binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); + // For codex-acp: when the adapter resolves as Available, probe the + // version. An adapter with major version < 1 is treated as outdated — + // the CODEX_CONFIG spawn contract requires 1.x. + if runtime.id == "codex" + && availability == AcpAvailabilityStatus::Available + && command.as_deref() == Some("codex-acp") + { + if let Some(path_str) = &binary_path { + availability = codex_adapter_availability(&PathBuf::from(path_str)); + } + } + + // Warm the adapter-availability cache for the badge fallback. + // The cache is scoped to the codex runtime; other runtimes leave it + // unchanged. Invalidated by `clear_resolve_cache`. + if runtime.id == "codex" { + cache_adapter_availability(availability.clone()); + } + let underlying_cli_path = runtime .underlying_cli .and_then(find_command) @@ -679,7 +1249,7 @@ pub fn discover_acp_runtimes() -> Vec { .map(|cmd| normalize_agent_args(cmd, Vec::new())) .unwrap_or_default(); - let can_auto_install = !runtime.cli_install_commands.is_empty() + let can_auto_install = !runtime.cli_install_commands_for_os().is_empty() || !runtime.adapter_install_commands.is_empty(); let cli_hint = runtime.cli_install_hint; @@ -688,6 +1258,7 @@ pub fn discover_acp_runtimes() -> Vec { AcpAvailabilityStatus::Available => cli_hint.to_string(), AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), + AcpAvailabilityStatus::AdapterOutdated => adapter_hint.to_string(), AcpAvailabilityStatus::NotInstalled => { if !cli_hint.is_empty() && !adapter_hint.is_empty() { format!("{cli_hint} {adapter_hint}") @@ -699,22 +1270,88 @@ pub fn discover_acp_runtimes() -> Vec { } }; - AcpRuntimeCatalogEntry { - id: runtime.id.to_string(), - label: runtime.label.to_string(), - avatar_url: runtime.avatar_url.to_string(), + // node_required: an npm adapter step is pending AND node/npm are absent. + let node_required = matches!( availability, - command, - binary_path, - default_args, - mcp_command: runtime.mcp_command.map(str::to_string), - install_hint, - install_instructions_url: runtime.install_instructions_url.to_string(), - can_auto_install, - underlying_cli_path, + AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled + ) && runtime_needs_npm(runtime) + && resolve_command("npm").is_none() + && resolve_command("node").is_none(); + + PartialEntry { + runtime, + entry: AcpRuntimeCatalogEntry { + id: runtime.id.to_string(), + label: runtime.label.to_string(), + avatar_url: runtime.avatar_url.to_string(), + availability, + command, + binary_path, + default_args, + mcp_command: runtime.mcp_command.map(str::to_string), + install_hint, + install_instructions_url: runtime.install_instructions_url.to_string(), + can_auto_install, + underlying_cli_path, + node_required, + // Filled in by the probe phase below. + auth_status: AuthStatus::Unknown, + login_hint: None, + }, } }) - .collect() + .collect(); + + // Phase 2: run auth probes in parallel for entries that need them. + // Spawn one thread per probeable entry; total cost = max(probe latency). + let probe_handles: Vec<(usize, std::thread::JoinHandle)> = partials + .iter() + .enumerate() + .filter_map(|(idx, partial)| { + if partial.entry.availability != AcpAvailabilityStatus::Available { + return None; + } + let probe_args = partial.runtime.auth_probe_args?; + // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). + let binary_path = resolve_command(probe_args[0])?; + let probe_args_owned: Vec = probe_args.iter().map(|s| s.to_string()).collect(); + + let handle = std::thread::spawn(move || { + let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); + probe_auth_status(&binary_path, &refs) + }); + Some((idx, handle)) + }) + .collect(); + + // Collect probe results and patch entries. + for (idx, handle) in probe_handles { + let status = handle.join().unwrap_or(AuthStatus::Unknown); + let partial = &mut partials[idx]; + partial.entry.login_hint = + if matches!(status, AuthStatus::LoggedIn | AuthStatus::NotApplicable) { + None + } else { + partial.runtime.login_hint.map(str::to_string) + }; + partial.entry.auth_status = status; + } + + // Fill NotApplicable / Unknown for non-probed entries. + for partial in &mut partials { + if partial.entry.auth_status == AuthStatus::Unknown { + partial.entry.auth_status = if partial.entry.availability + == AcpAvailabilityStatus::Available + && partial.runtime.auth_probe_args.is_none() + { + AuthStatus::NotApplicable + } else { + AuthStatus::Unknown + }; + } + } + + partials.into_iter().map(|p| p.entry).collect() } pub fn managed_agent_avatar_url(command: &str) -> Option { @@ -723,401 +1360,4 @@ pub fn managed_agent_avatar_url(command: &str) -> Option { } #[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::{ - classify_runtime, create_time_agent_command_override, default_agent_command, - divergent_agent_command_override, effective_agent_command, find_via_login_shell, - managed_agent_avatar_url, normalize_agent_args, BUZZ_AGENT_AVATAR_URL, - CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, - }; - use crate::managed_agents::AcpAvailabilityStatus; - - #[test] - fn resolves_known_avatar_for_bare_command() { - let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve"); - - assert_eq!(avatar_url, GOOSE_AVATAR_URL); - } - - #[test] - fn resolves_known_avatar_for_command_paths_and_aliases() { - assert_eq!( - managed_agent_avatar_url("/usr/local/bin/codex-acp"), - Some(CODEX_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url("Claude Code"), - Some(CLAUDE_CODE_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"), - Some(CLAUDE_CODE_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url("/usr/local/bin/claude-code-acp"), - Some(CLAUDE_CODE_AVATAR_URL.to_string()) - ); - } - - #[test] - fn returns_none_for_unknown_commands() { - assert!(managed_agent_avatar_url("custom-agent").is_none()); - } - - #[test] - fn default_agent_command_resolves_bundled_buzz_agent() { - // The create-path default must be the bundled buzz-agent, never the - // bare `goose` that isn't on PATH on a stock Windows install. - assert_eq!(default_agent_command(), "buzz-agent"); - // And buzz-agent takes no `acp` arg — confirm no arg leakage from the default. - assert_eq!( - normalize_agent_args(&default_agent_command(), vec!["acp".into()]), - Vec::::new() - ); - } - - #[test] - fn normalizes_claude_and_codex_args_to_empty() { - assert_eq!( - normalize_agent_args("claude-agent-acp", vec!["acp".into()]), - Vec::::new() - ); - assert_eq!( - normalize_agent_args("claude-code-acp", vec!["acp".into()]), - Vec::::new() - ); - assert_eq!( - normalize_agent_args("codex-acp", vec!["acp".into()]), - Vec::::new() - ); - } - - #[test] - fn resolves_buzz_agent_avatar() { - assert_eq!( - managed_agent_avatar_url("buzz-agent"), - Some(BUZZ_AGENT_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url("/usr/local/bin/buzz-agent"), - Some(BUZZ_AGENT_AVATAR_URL.to_string()) - ); - } - - #[test] - fn normalizes_buzz_agent_args_to_empty() { - assert_eq!( - normalize_agent_args("buzz-agent", Vec::new()), - Vec::::new() - ); - assert_eq!( - normalize_agent_args("buzz-agent", vec!["acp".into()]), - Vec::::new() - ); - } - - #[test] - fn login_shell_lookup_treats_command_as_data() { - let marker = - std::env::temp_dir().join(format!("buzz-discovery-marker-{}", uuid::Uuid::new_v4())); - let payload = format!("doesnotexist; touch {} #", marker.display()); - - let resolved = find_via_login_shell(&payload); - - assert!( - resolved.is_none(), - "payload should not resolve to a command" - ); - assert!( - !marker.exists(), - "shell lookup must not execute injected commands" - ); - } - - #[cfg(unix)] - #[test] - fn explicit_path_resolution_ignores_non_executable_files() { - use std::os::unix::fs::PermissionsExt; - - let dir = - std::env::temp_dir().join(format!("buzz-discovery-path-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("buzz-acp"); - std::fs::write(&bin, "").expect("write placeholder"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o644)) - .expect("chmod placeholder"); - - assert!( - super::resolve_workspace_command(bin.to_str().expect("utf8 path")).is_none(), - "non-executable placeholder must not resolve" - ); - - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) - .expect("chmod executable"); - assert_eq!( - super::resolve_workspace_command(bin.to_str().expect("utf8 path")), - Some(bin.clone()) - ); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn classifies_available_when_adapter_found() { - let (status, cmd, path) = classify_runtime( - Some(("goose", PathBuf::from("/usr/local/bin/goose"))), - None, - false, - ); - assert_eq!(status, AcpAvailabilityStatus::Available); - assert_eq!(cmd.as_deref(), Some("goose")); - assert_eq!(path.as_deref(), Some("/usr/local/bin/goose")); - } - - #[test] - fn classifies_adapter_missing_when_cli_present() { - let (status, cmd, path) = classify_runtime(None, Some("claude"), true); - assert_eq!(status, AcpAvailabilityStatus::AdapterMissing); - assert!(cmd.is_none()); - assert!(path.is_none()); - } - - #[test] - fn classifies_not_installed_when_nothing_found() { - let (status, cmd, path) = classify_runtime(None, Some("claude"), false); - assert_eq!(status, AcpAvailabilityStatus::NotInstalled); - assert!(cmd.is_none()); - assert!(path.is_none()); - } - - #[test] - fn classifies_not_installed_when_no_underlying_cli() { - let (status, cmd, path) = classify_runtime(None, None, false); - assert_eq!(status, AcpAvailabilityStatus::NotInstalled); - assert!(cmd.is_none()); - assert!(path.is_none()); - } - - #[test] - fn classifies_cli_missing_when_adapter_found_but_cli_absent() { - let (status, cmd, path) = classify_runtime( - Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))), - Some("codex"), - false, - ); - assert_eq!(status, AcpAvailabilityStatus::CliMissing); - assert_eq!(cmd.as_deref(), Some("codex-acp")); - assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); - } - - fn persona_with_runtime( - id: &str, - runtime: Option<&str>, - ) -> crate::managed_agents::PersonaRecord { - crate::managed_agents::PersonaRecord { - id: id.to_string(), - display_name: id.to_string(), - avatar_url: None, - system_prompt: String::new(), - runtime: runtime.map(str::to_string), - model: None, - provider: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - env_vars: std::collections::BTreeMap::new(), - created_at: "2026-06-09T00:00:00Z".to_string(), - updated_at: "2026-06-09T00:00:00Z".to_string(), - } - } - - #[test] - fn effective_agent_command_explicit_override_wins() { - // An explicit pin beats the persona's runtime. - let personas = vec![persona_with_runtime("p1", Some("claude"))]; - assert_eq!( - effective_agent_command(Some("p1"), &personas, Some("codex-acp")), - "codex-acp" - ); - } - - #[test] - fn effective_agent_command_inherits_persona_runtime() { - // No override → persona runtime id maps to its primary command. - let personas = vec![persona_with_runtime("p1", Some("claude"))]; - assert_eq!( - effective_agent_command(Some("p1"), &personas, None), - "claude-agent-acp" - ); - } - - #[test] - fn effective_agent_command_empty_override_is_inherit() { - // A blank/whitespace override is treated as "inherit", not a pin. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - assert_eq!( - effective_agent_command(Some("p1"), &personas, Some(" ")), - "goose" - ); - } - - #[test] - fn effective_agent_command_falls_back_to_default() { - // No override, no persona runtime, and a deleted persona all fall back - // to the bundled default. - let personas = vec![persona_with_runtime("p1", None)]; - assert_eq!( - effective_agent_command(Some("p1"), &personas, None), - default_agent_command() - ); - assert_eq!( - effective_agent_command(Some("gone"), &personas, None), - default_agent_command() - ); - assert_eq!( - effective_agent_command(None, &personas, None), - default_agent_command() - ); - } - - #[test] - fn divergent_override_none_when_picked_matches_persona_runtime() { - // The persona-backed create/edit flow sends the persona's resolved - // command. It must be treated as "inherit" (None), not a pin. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - assert_eq!( - divergent_agent_command_override(Some("p1"), &personas, Some("goose")), - None - ); - } - - #[test] - fn divergent_override_none_for_alternate_command_of_same_runtime() { - // A client with only `claude-code-acp` installed sends that command for - // a `claude` persona whose primary command is `claude-agent-acp`. Both - // map to the `claude` runtime, so it inherits — string equality would - // wrongly bake a pin (CRITICAL-3). - let personas = vec![persona_with_runtime("p1", Some("claude"))]; - assert_eq!( - divergent_agent_command_override(Some("p1"), &personas, Some("claude-code-acp")), - None - ); - } - - #[test] - fn divergent_override_some_when_picked_is_different_runtime() { - // A deliberate pin to a different runtime is preserved. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - assert_eq!( - divergent_agent_command_override(Some("p1"), &personas, Some("codex-acp")), - Some("codex-acp".to_string()) - ); - } - - #[test] - fn divergent_override_none_for_empty_or_absent_pick() { - // The "Inherit from persona" sentinel (empty) and a name-only edit - // (absent) both clear the pin. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - assert_eq!( - divergent_agent_command_override(Some("p1"), &personas, Some(" ")), - None - ); - assert_eq!( - divergent_agent_command_override(Some("p1"), &personas, None), - None - ); - } - - #[test] - fn create_time_override_none_when_persona_runtime_not_installed() { - // CRITICAL-3 (Case 3): a `claude`-persona agent created on a machine - // where the claude adapter isn't installed. `resolvePersonaRuntime` - // falls back to the default (`buzz-agent`) and sends THAT command with - // `harness_override` false (the user did not pick it). At create this - // is a fallback, not a deliberate pin — it must store `None` so the - // agent inherits the persona's runtime once it's installed and the - // persona is re-edited. Baking `Some("buzz-agent")` here is the exact - // bug this resolver chain exists to kill. - let personas = vec![persona_with_runtime("p1", Some("claude"))]; - assert_eq!( - create_time_agent_command_override(Some("p1"), &personas, Some("buzz-agent"), false), - None - ); - } - - #[test] - fn create_time_override_some_when_user_deliberately_overrides_installed_runtime() { - // Case 2 + deliberate override: the persona's `claude` runtime IS - // available, but the user explicitly picked `codex` in a deploy dialog's - // runtime selector ("overriding persona preferences"), so the frontend - // sends `codex-acp` with `harness_override` true. This is a real pin and - // MUST be preserved — returning `None` would silently swallow the - // deliberate override and inherit `claude` on spawn. - let personas = vec![persona_with_runtime("p1", Some("claude"))]; - assert_eq!( - create_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), true), - Some("codex-acp".to_string()) - ); - } - - #[test] - fn create_time_override_none_when_persona_runtime_installed() { - // Case 2: the persona's runtime is available, so `resolvePersonaRuntime` - // sends the persona's own command with no override. Inherits — no pin. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - assert_eq!( - create_time_agent_command_override(Some("p1"), &personas, Some("goose"), false), - None - ); - } - - #[test] - fn create_time_override_preserves_selected_runtime_alias() { - // A `claude` persona inherits the primary command `claude-agent-acp`, - // but discovery may select an installed alias such as `claude-code-acp`. - // When UI marks that create-time selection as explicit, preserve the - // alias so the first spawn uses a command known to be installed. - let personas = vec![persona_with_runtime("p1", Some("claude"))]; - assert_eq!( - create_time_agent_command_override( - Some("p1"), - &personas, - Some("claude-code-acp"), - true - ), - Some("claude-code-acp".to_string()) - ); - } - - #[test] - fn create_time_override_inherits_exact_persona_command() { - let personas = vec![persona_with_runtime("p1", Some("claude"))]; - assert_eq!( - create_time_agent_command_override( - Some("p1"), - &personas, - Some("claude-agent-acp"), - true - ), - None - ); - } - - #[test] - fn create_time_override_preserves_pin_for_persona_less_create() { - // The standalone CreateAgentDialog creates persona-LESS agents. With no - // persona to inherit, the picked command IS the agent's harness and must - // be preserved as a real pin (divergence from the bundled default), - // regardless of the override flag. - let personas = vec![persona_with_runtime("p1", Some("goose"))]; - assert_eq!( - create_time_agent_command_override(None, &personas, Some("codex-acp"), false), - Some("codex-acp".to_string()) - ); - } -} +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs new file mode 100644 index 0000000000..5140bb2cdd --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -0,0 +1,153 @@ +//! The `agent_command_override` decision family: divergence comparison, +//! create-time and update-time resolution, and the record apply. Child module +//! of `discovery` (split under the desktop file-size cap) so the override +//! decisions stay next to the resolution ladder they feed. + +use super::{effective_agent_command, known_acp_runtime}; + +/// Decide whether a user-picked harness command is an explicit per-instance +/// pin or merely the persona's own runtime restated. Returns the override to +/// persist: `Some(picked)` when it diverges from the persona, `None` when it +/// inherits. +/// +/// Comparison is by RUNTIME IDENTITY, not raw string: a persona on the `claude` +/// runtime resolves to `claude-agent-acp`, but a client with only the +/// `claude-code-acp` adapter installed sends that command instead. Both map to +/// the same `claude` runtime, so neither is a real divergence — string equality +/// would wrongly bake a pin. An unknown/custom command (no matching runtime) +/// only inherits when it exactly equals the persona command. +pub fn divergent_agent_command_override( + persona_id: Option<&str>, + personas: &[crate::managed_agents::types::AgentDefinition], + picked_command: Option<&str>, +) -> Option { + let picked = picked_command + .map(str::trim) + .filter(|value| !value.is_empty())?; + let persona_command = effective_agent_command(persona_id, personas, None); + let same_runtime = match ( + known_acp_runtime(picked), + known_acp_runtime(&persona_command), + ) { + (Some(a), Some(b)) => std::ptr::eq(a, b), + _ => picked == persona_command, + }; + if same_runtime { + None + } else { + Some(picked.to_string()) + } +} + +/// Decide the `agent_command_override` to persist at AGENT UPDATE time. +/// +/// The edit dialog sends `agent_command` as a tri-state string: the empty +/// "inherit from persona" sentinel (clear the pin), or a concrete command +/// (pin). Resolution: +/// +/// - EMPTY / whitespace → the inherit sentinel: always `None` regardless of +/// `harness_override`, so toggling "Inherit runtime from persona" clears the +/// pin. +/// - DELIBERATE OVERRIDE (`harness_override` true, persona linked): the user +/// explicitly picked a runtime/Custom command in the dialog. This is a real +/// pin and is preserved VERBATIM — even when the picked command maps to, or +/// is byte-identical to, the persona's own runtime command. Selecting "Custom +/// command" and saving e.g. `goose` for a goose persona is a deliberate act +/// to freeze the harness against future persona runtime edits; dropping it +/// back to inherit (as [`divergent_agent_command_override`] would) defeats +/// that intent. Unlike the create-time path, there is no byte-identical +/// exception here: at create the command is machine-derived from the persona, +/// so equality means "no user divergence"; at update an equal command reached +/// the force branch only because the user picked Custom, which IS the +/// divergence. +/// - NO OVERRIDE INTENT (`harness_override` false) or NO PERSONA: defer to +/// [`divergent_agent_command_override`], which keeps the persona authoritative +/// and treats a same-runtime restatement as inherit. +pub fn update_time_agent_command_override( + persona_id: Option<&str>, + personas: &[crate::managed_agents::types::AgentDefinition], + picked_command: Option<&str>, + harness_override: bool, +) -> Option { + let picked = picked_command + .map(str::trim) + .filter(|value| !value.is_empty())?; + + if persona_id.is_some() && harness_override { + return Some(picked.to_string()); + } + + divergent_agent_command_override(persona_id, personas, Some(picked)) +} + +/// Apply an explicit `agent_command` edit to `record`: persist the override +/// pin decided by [`update_time_agent_command_override`], and on the inherit +/// sentinel (empty/whitespace command) also clear the materialized +/// `record.runtime` so the resolution ladder falls through to the live +/// definition immediately instead of silently keeping the stale instance copy. +/// +/// The runtime clear is guarded on a live persona link: for a definition-less +/// record the materialized runtime is the only harness source left after the +/// override clear, so a stray empty `agent_command` from a non-dialog caller +/// must not change what the agent runs. +pub fn apply_agent_command_update( + record: &mut crate::managed_agents::types::ManagedAgentRecord, + personas: &[crate::managed_agents::types::AgentDefinition], + agent_command: &str, + harness_override: bool, +) { + record.agent_command_override = update_time_agent_command_override( + record.persona_id.as_deref(), + personas, + Some(agent_command), + harness_override, + ); + if agent_command.trim().is_empty() && record.persona_id.is_some() { + record.runtime = None; + } +} + +/// Decide the `agent_command_override` to persist at AGENT CREATE time. +/// +/// A persona-backed create receives its harness command from +/// `resolvePersonaRuntime` (frontend), which produces a divergent command in two +/// distinct cases that the backend MUST tell apart: +/// +/// - DELIBERATE OVERRIDE (`harness_override` true): the user explicitly picked a +/// runtime command in UI that exposes a runtime selector. This is a real pin +/// and is preserved when it differs from the command inheritance would spawn, +/// including installed aliases such as `claude-code-acp`. +/// - MISSING-RUNTIME FALLBACK (`harness_override` false): the persona's runtime +/// isn't installed locally, so `resolvePersonaRuntime` substitutes a fallback +/// default. This is NOT a pin — baking it would freeze the agent on the fallback +/// harness even after the persona's runtime is installed and the persona is +/// re-edited, the exact bug this resolver chain exists to prevent. Stores `None` +/// so the persona stays authoritative. +/// +/// `isOverridden` from `resolvePersonaRuntime` cannot distinguish these — it is +/// `true` for BOTH — so the caller must thread the explicit user-intent bit. +/// +/// Persona-less creates (`persona_id` is `None`, e.g. the standalone +/// CreateAgentDialog) have no persona to inherit, so the picked command is always a +/// real pin and is preserved via `divergent_agent_command_override` regardless of +/// `harness_override`. +pub fn create_time_agent_command_override( + persona_id: Option<&str>, + personas: &[crate::managed_agents::types::AgentDefinition], + picked_command: Option<&str>, + harness_override: bool, +) -> Option { + if persona_id.is_some() && !harness_override { + return None; + } + + if persona_id.is_some() && harness_override { + let picked = picked_command + .map(str::trim) + .filter(|value| !value.is_empty())?; + let inherited_command = effective_agent_command(persona_id, personas, None); + return (picked != inherited_command).then(|| picked.to_string()); + } + + divergent_agent_command_override(persona_id, personas, picked_command) +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs new file mode 100644 index 0000000000..608b511fa6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -0,0 +1,1270 @@ +use std::path::PathBuf; + +use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; +use super::{ + apply_agent_command_update, classify_runtime, codex_adapter_availability, + codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, + effective_agent_command, find_nvm_default_bin, find_via_login_shell, + is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, + parse_semver_tag, probe_codex_acp_major_version, record_agent_command, + refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, + GOOSE_AVATAR_URL, +}; +use crate::managed_agents::AcpAvailabilityStatus; + +#[test] +fn resolves_known_avatar_for_bare_command() { + let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve"); + + assert_eq!(avatar_url, GOOSE_AVATAR_URL); +} + +#[test] +fn resolves_known_avatar_for_command_paths_and_aliases() { + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/codex-acp"), + Some(CODEX_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("Claude Code"), + Some(CLAUDE_CODE_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"), + Some(CLAUDE_CODE_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/claude-code-acp"), + Some(CLAUDE_CODE_AVATAR_URL.to_string()) + ); +} + +#[test] +fn returns_none_for_unknown_commands() { + assert!(managed_agent_avatar_url("custom-agent").is_none()); +} + +#[test] +fn default_agent_command_resolves_bundled_buzz_agent() { + // The create-path default must be the bundled buzz-agent, never the + // bare `goose` that isn't on PATH on a stock Windows install. + assert_eq!(default_agent_command(), "buzz-agent"); + // And buzz-agent takes no `acp` arg — confirm no arg leakage from the default. + assert_eq!( + normalize_agent_args(&default_agent_command(), vec!["acp".into()]), + Vec::::new() + ); +} + +#[test] +fn normalizes_claude_and_codex_args_to_empty() { + assert_eq!( + normalize_agent_args("claude-agent-acp", vec!["acp".into()]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("claude-code-acp", vec!["acp".into()]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("codex-acp", vec!["acp".into()]), + Vec::::new() + ); +} + +#[test] +fn resolves_buzz_agent_avatar() { + assert_eq!( + managed_agent_avatar_url("buzz-agent"), + Some(BUZZ_AGENT_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/buzz-agent"), + Some(BUZZ_AGENT_AVATAR_URL.to_string()) + ); +} + +#[test] +fn normalizes_buzz_agent_args_to_empty() { + assert_eq!( + normalize_agent_args("buzz-agent", Vec::new()), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("buzz-agent", vec!["acp".into()]), + Vec::::new() + ); +} + +#[test] +fn login_shell_lookup_treats_command_as_data() { + let marker = + std::env::temp_dir().join(format!("buzz-discovery-marker-{}", uuid::Uuid::new_v4())); + let payload = format!("doesnotexist; touch {} #", marker.display()); + + let resolved = find_via_login_shell(&payload); + + assert!( + resolved.is_none(), + "payload should not resolve to a command" + ); + assert!( + !marker.exists(), + "shell lookup must not execute injected commands" + ); +} + +#[cfg(unix)] +#[test] +fn explicit_path_resolution_ignores_non_executable_files() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("buzz-discovery-path-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let bin = dir.join("buzz-acp"); + std::fs::write(&bin, "").expect("write placeholder"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o644)) + .expect("chmod placeholder"); + + assert!( + super::resolve_workspace_command(bin.to_str().expect("utf8 path")).is_none(), + "non-executable placeholder must not resolve" + ); + + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod executable"); + assert_eq!( + super::resolve_workspace_command(bin.to_str().expect("utf8 path")), + Some(bin.clone()) + ); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn classifies_available_when_adapter_found() { + let (status, cmd, path) = classify_runtime( + Some(("goose", PathBuf::from("/usr/local/bin/goose"))), + None, + false, + ); + assert_eq!(status, AcpAvailabilityStatus::Available); + assert_eq!(cmd.as_deref(), Some("goose")); + assert_eq!(path.as_deref(), Some("/usr/local/bin/goose")); +} + +#[test] +fn classifies_adapter_missing_when_cli_present() { + let (status, cmd, path) = classify_runtime(None, Some("claude"), true); + assert_eq!(status, AcpAvailabilityStatus::AdapterMissing); + assert!(cmd.is_none()); + assert!(path.is_none()); +} + +#[test] +fn classifies_not_installed_when_nothing_found() { + let (status, cmd, path) = classify_runtime(None, Some("claude"), false); + assert_eq!(status, AcpAvailabilityStatus::NotInstalled); + assert!(cmd.is_none()); + assert!(path.is_none()); +} + +#[test] +fn classifies_not_installed_when_no_underlying_cli() { + let (status, cmd, path) = classify_runtime(None, None, false); + assert_eq!(status, AcpAvailabilityStatus::NotInstalled); + assert!(cmd.is_none()); + assert!(path.is_none()); +} + +#[test] +fn classifies_cli_missing_when_adapter_found_but_cli_absent() { + let (status, cmd, path) = classify_runtime( + Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))), + Some("codex"), + false, + ); + assert_eq!(status, AcpAvailabilityStatus::CliMissing); + assert_eq!(cmd.as_deref(), Some("codex-acp")); + assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); +} + +fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition { + crate::managed_agents::AgentDefinition { + id: id.to_string(), + display_name: id.to_string(), + avatar_url: None, + system_prompt: String::new(), + runtime: runtime.map(str::to_string), + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-06-09T00:00:00Z".to_string(), + updated_at: "2026-06-09T00:00:00Z".to_string(), + } +} + +#[test] +fn effective_agent_command_explicit_override_wins() { + // An explicit pin beats the persona's runtime. + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + effective_agent_command(Some("p1"), &personas, Some("codex-acp")), + "codex-acp" + ); +} + +/// Minimal record for `record_agent_command` tests. Only the resolution +/// inputs (runtime / persona_id / agent_command_override) vary. +fn record_with( + runtime: Option<&str>, + persona_id: Option<&str>, + override_cmd: Option<&str>, +) -> crate::managed_agents::types::ManagedAgentRecord { + crate::managed_agents::types::ManagedAgentRecord { + pubkey: String::new(), + name: "r".to_string(), + persona_id: persona_id.map(str::to_string), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: override_cmd.map(str::to_string), + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + env_vars: std::collections::BTreeMap::new(), + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: runtime.map(str::to_string), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} + +#[test] +fn record_agent_command_own_runtime_wins_over_persona() { + // A record with its own materialized runtime never consults the + // persona list — the unified-model resolution. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let record = record_with(Some("claude"), Some("p1"), None); + assert_eq!(record_agent_command(&record, &personas), "claude-agent-acp"); +} + +#[test] +fn record_agent_command_override_beats_runtime() { + let record = record_with(Some("claude"), None, Some("codex-acp")); + assert_eq!(record_agent_command(&record, &[]), "codex-acp"); +} + +#[test] +fn record_agent_command_legacy_persona_fallback() { + // Pre-migration record: persona_id set, no runtime — resolves through + // the legacy persona path unchanged. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let record = record_with(None, Some("p1"), None); + assert_eq!(record_agent_command(&record, &personas), "goose"); +} + +#[test] +fn record_agent_command_bare_record_defaults() { + let record = record_with(None, None, None); + assert_eq!(record_agent_command(&record, &[]), default_agent_command()); +} + +#[test] +fn effective_agent_command_inherits_persona_runtime() { + // No override → persona runtime id maps to its primary command. + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + effective_agent_command(Some("p1"), &personas, None), + "claude-agent-acp" + ); +} + +#[test] +fn effective_agent_command_empty_override_is_inherit() { + // A blank/whitespace override is treated as "inherit", not a pin. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + effective_agent_command(Some("p1"), &personas, Some(" ")), + "goose" + ); +} + +#[test] +fn effective_agent_command_falls_back_to_default() { + // No override, no persona runtime, and a deleted persona all fall back + // to the bundled default. + let personas = vec![persona_with_runtime("p1", None)]; + assert_eq!( + effective_agent_command(Some("p1"), &personas, None), + default_agent_command() + ); + assert_eq!( + effective_agent_command(Some("gone"), &personas, None), + default_agent_command() + ); + assert_eq!( + effective_agent_command(None, &personas, None), + default_agent_command() + ); +} + +#[test] +fn divergent_override_none_when_picked_matches_persona_runtime() { + // The persona-backed create/edit flow sends the persona's resolved + // command. It must be treated as "inherit" (None), not a pin. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + divergent_agent_command_override(Some("p1"), &personas, Some("goose")), + None + ); +} + +#[test] +fn divergent_override_none_for_alternate_command_of_same_runtime() { + // A client with only `claude-code-acp` installed sends that command for + // a `claude` persona whose primary command is `claude-agent-acp`. Both + // map to the `claude` runtime, so it inherits — string equality would + // wrongly bake a pin (CRITICAL-3). + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + divergent_agent_command_override(Some("p1"), &personas, Some("claude-code-acp")), + None + ); +} + +#[test] +fn divergent_override_some_when_picked_is_different_runtime() { + // A deliberate pin to a different runtime is preserved. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + divergent_agent_command_override(Some("p1"), &personas, Some("codex-acp")), + Some("codex-acp".to_string()) + ); +} + +#[test] +fn divergent_override_none_for_empty_or_absent_pick() { + // The "Inherit from persona" sentinel (empty) and a name-only edit + // (absent) both clear the pin. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + divergent_agent_command_override(Some("p1"), &personas, Some(" ")), + None + ); + assert_eq!( + divergent_agent_command_override(Some("p1"), &personas, None), + None + ); +} + +#[test] +fn create_time_override_none_when_persona_runtime_not_installed() { + // CRITICAL-3 (Case 3): a `claude`-persona agent created on a machine + // where the claude adapter isn't installed. `resolvePersonaRuntime` + // falls back to the default (`buzz-agent`) and sends THAT command with + // `harness_override` false (the user did not pick it). At create this + // is a fallback, not a deliberate pin — it must store `None` so the + // agent inherits the persona's runtime once it's installed and the + // persona is re-edited. Baking `Some("buzz-agent")` here is the exact + // bug this resolver chain exists to kill. + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + create_time_agent_command_override(Some("p1"), &personas, Some("buzz-agent"), false), + None + ); +} + +#[test] +fn create_time_override_some_when_user_deliberately_overrides_installed_runtime() { + // Case 2 + deliberate override: the persona's `claude` runtime IS + // available, but the user explicitly picked `codex` in a deploy dialog's + // runtime selector ("overriding persona preferences"), so the frontend + // sends `codex-acp` with `harness_override` true. This is a real pin and + // MUST be preserved — returning `None` would silently swallow the + // deliberate override and inherit `claude` on spawn. + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + create_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), true), + Some("codex-acp".to_string()) + ); +} + +#[test] +fn create_time_override_none_when_persona_runtime_installed() { + // Case 2: the persona's runtime is available, so `resolvePersonaRuntime` + // sends the persona's own command with no override. Inherits — no pin. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + create_time_agent_command_override(Some("p1"), &personas, Some("goose"), false), + None + ); +} + +#[test] +fn create_time_override_preserves_selected_runtime_alias() { + // A `claude` persona inherits the primary command `claude-agent-acp`, + // but discovery may select an installed alias such as `claude-code-acp`. + // When UI marks that create-time selection as explicit, preserve the + // alias so the first spawn uses a command known to be installed. + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + create_time_agent_command_override(Some("p1"), &personas, Some("claude-code-acp"), true), + Some("claude-code-acp".to_string()) + ); +} + +#[test] +fn create_time_override_inherits_exact_persona_command() { + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + create_time_agent_command_override(Some("p1"), &personas, Some("claude-agent-acp"), true), + None + ); +} + +#[test] +fn create_time_override_preserves_pin_for_persona_less_create() { + // The standalone CreateAgentDialog creates persona-LESS agents. With no + // persona to inherit, the picked command IS the agent's harness and must + // be preserved as a real pin (divergence from the bundled default), + // regardless of the override flag. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + create_time_agent_command_override(None, &personas, Some("codex-acp"), false), + Some("codex-acp".to_string()) + ); +} + +#[test] +fn update_time_override_preserves_same_runtime_pin_when_overriding() { + // The bug this fixes: the user picks "Custom command" in the edit + // dialog and saves `goose` verbatim for a goose persona. That is a + // deliberate pin (harness_override true) — it must be kept so future + // persona runtime edits stop propagating, even though it maps to the + // persona's own runtime. `divergent_agent_command_override` alone would + // wrongly drop it to `None`. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, Some("goose"), true), + Some("goose".to_string()) + ); +} + +#[test] +fn update_time_override_preserves_exact_persona_command_when_overriding() { + // Even when the pick is byte-identical to the persona's own command, an + // explicit Custom selection (harness_override true) is a deliberate pin + // and is preserved. This is the core divergence from the create-time + // contract: at update, equality reached the force branch only because + // the user picked Custom. + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, Some("claude-agent-acp"), true), + Some("claude-agent-acp".to_string()) + ); +} + +#[test] +fn update_time_override_preserves_alias_pin_when_overriding() { + // A `claude` persona with an installed `claude-code-acp` alias: picking + // it as a Custom pin is a deliberate divergence from the primary + // command and must be preserved when overriding. + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, Some("claude-code-acp"), true), + Some("claude-code-acp".to_string()) + ); +} + +#[test] +fn update_time_override_defers_to_divergent_when_not_overriding() { + // Without the explicit intent bit (e.g. a name-only edit that still + // echoes the command), the persona stays authoritative: a same-runtime + // command inherits, a different runtime pins. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, Some("goose"), false), + None + ); + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), false), + Some("codex-acp".to_string()) + ); +} + +#[test] +fn update_time_override_clears_pin_for_inherit_sentinel() { + // The empty "Inherit from persona" sentinel always clears the pin, + // regardless of the override flag. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, Some(" "), true), + None + ); + assert_eq!( + update_time_agent_command_override(Some("p1"), &personas, None, true), + None + ); +} + +#[test] +fn update_time_override_preserves_pin_for_persona_less_agent() { + // A persona-less agent has no runtime to inherit, so any picked command + // is a real pin — preserved even without the override flag (mirrors the + // create-time persona-less contract). + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + assert_eq!( + update_time_agent_command_override(None, &personas, Some("codex-acp"), false), + Some("codex-acp".to_string()) + ); +} + +#[test] +fn apply_agent_command_update_inherit_sentinel_clears_pin_and_runtime() { + // Choosing Inherit on a persona-linked record clears BOTH the explicit + // pin and the materialized runtime, so resolution falls through to the + // live definition immediately — not on the next spawn. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); + + apply_agent_command_update(&mut record, &personas, "", false); + + assert_eq!(record.agent_command_override, None); + assert_eq!(record.runtime, None); + assert_eq!(record_agent_command(&record, &personas), "goose"); +} + +#[test] +fn apply_agent_command_update_sentinel_keeps_runtime_for_definition_less_record() { + // For a record with no persona link the materialized runtime is the only + // harness source left once the pin is cleared — a stray empty + // agent_command must not change what the agent runs. + let mut record = record_with(Some("claude"), None, Some("codex-acp")); + + apply_agent_command_update(&mut record, &[], "", false); + + assert_eq!(record.agent_command_override, None); + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!(record_agent_command(&record, &[]), "claude-agent-acp"); +} + +#[test] +fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { + // A concrete pick only sets the pin; the materialized runtime is left for + // the next snapshot apply. The pin shadows it in resolution either way. + let personas = vec![persona_with_runtime("p1", Some("goose"))]; + let mut record = record_with(Some("claude"), Some("p1"), None); + + apply_agent_command_update(&mut record, &personas, "codex-acp", true); + + assert_eq!(record.agent_command_override.as_deref(), Some("codex-acp")); + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!(record_agent_command(&record, &personas), "codex-acp"); +} + +// ── probe_codex_acp_major_version ───────────────────────────────────────────── + +#[cfg(unix)] +#[test] +fn probe_codex_acp_major_version_parses_1x_output() { + use std::os::unix::fs::PermissionsExt; + + // Simulate `@agentclientprotocol/codex-acp 1.1.2` output (1.x adapter) + let dir = std::env::temp_dir().join(format!("buzz-probe-1x-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let bin = dir.join("codex-acp"); + std::fs::write( + &bin, + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let major = probe_codex_acp_major_version(&bin); + let _ = std::fs::remove_dir_all(dir); + + assert_eq!(major, Some(1), "1.x adapter must return major version 1"); +} + +mod codex_version; + +#[cfg(unix)] +#[test] +fn probe_codex_acp_major_version_returns_none_for_nonzero_exit() { + use std::os::unix::fs::PermissionsExt; + + // Simulate old 0.16.x adapter: `--version` is unrecognised, exits non-zero + let dir = std::env::temp_dir().join(format!("buzz-probe-0x-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let bin = dir.join("codex-acp"); + std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let major = probe_codex_acp_major_version(&bin); + let _ = std::fs::remove_dir_all(dir); + + assert_eq!( + major, None, + "old 0.16.x adapter (non-zero exit) must return None" + ); +} + +#[cfg(unix)] +#[test] +fn probe_codex_acp_major_version_returns_none_for_missing_binary() { + let path = std::path::Path::new("/nonexistent/path/codex-acp-does-not-exist"); + let major = probe_codex_acp_major_version(path); + assert_eq!(major, None, "missing binary must return None"); +} + +// ── codex_adapter_availability / codex_adapter_is_outdated ─────────────────── +// +// Outcome-level classification: verify helpers map probe results to the correct +// AcpAvailabilityStatus and boolean without duplicating version-gate logic. + +#[cfg(unix)] +#[test] +fn codex_adapter_availability_available_for_1x_binary() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("buzz-avail-1x-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let bin = dir.join("codex-acp"); + std::fs::write( + &bin, + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let status = codex_adapter_availability(&bin); + let _ = std::fs::remove_dir_all(dir); + + assert_eq!( + status, + AcpAvailabilityStatus::Available, + "1.x adapter must classify as Available" + ); +} + +#[cfg(unix)] +#[test] +fn codex_adapter_availability_outdated_for_0x_binary() { + use std::os::unix::fs::PermissionsExt; + + // Simulate old 0.16.x: `--version` exits non-zero (unrecognised flag) + let dir = std::env::temp_dir().join(format!("buzz-avail-0x-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let bin = dir.join("codex-acp"); + std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let status = codex_adapter_availability(&bin); + let _ = std::fs::remove_dir_all(dir); + + assert_eq!( + status, + AcpAvailabilityStatus::AdapterOutdated, + "0.x adapter (non-zero exit) must classify as AdapterOutdated" + ); +} + +#[cfg(unix)] +#[test] +fn codex_adapter_availability_outdated_for_missing_binary() { + let path = std::path::Path::new("/nonexistent/codex-acp-probe-test"); + assert_eq!( + codex_adapter_availability(path), + AcpAvailabilityStatus::AdapterOutdated, + "missing binary must classify as AdapterOutdated" + ); + // Thin wrapper consistency + assert!( + codex_adapter_is_outdated(path), + "missing binary must be classified as outdated via thin wrapper" + ); +} + +#[cfg(unix)] +#[test] +fn probe_codex_acp_major_version_returns_none_for_hung_direct_child() { + use std::os::unix::fs::PermissionsExt; + use std::time::Instant; + + // Simulate a process that writes version to stdout then blocks forever. + // The probe reads stdout only after the child exits, so it will time out. + // `exec sleep 300` replaces the shell so killing the child reaps `sleep` too. + let dir = std::env::temp_dir().join(format!("buzz-probe-hung-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let bin = dir.join("codex-acp"); + std::fs::write( + &bin, + "#!/bin/sh\nprintf '@agentclientprotocol/codex-acp 1.1.2\\n'\nexec sleep 300\n", + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let start = Instant::now(); + let major = probe_codex_acp_major_version(&bin); + let elapsed = start.elapsed(); + let _ = std::fs::remove_dir_all(dir); + + assert_eq!( + major, None, + "hung binary must return None (timeout kills child)" + ); + // The timeout is 5 s; give a 10 s margin for parallel pre-push suites. + assert!( + elapsed.as_secs() < 15, + "probe must complete within timeout bound; elapsed: {elapsed:?}" + ); +} + +#[cfg(unix)] +#[test] +fn probe_codex_acp_major_version_returns_version_when_descendant_holds_pipe_open() { + use std::os::unix::fs::PermissionsExt; + use std::time::Instant; + + // Simulate a process that forks a background child which inherits stdout + // and stays alive, while the parent writes version and exits 0. + // + // The probe writes the child's stdout to a temp file, then reads from the + // file after the parent process exits. Because the file has reached EOF + // (the parent closed its write end), read_to_end() returns immediately + // without waiting for the descendant to close its inherited fd. + // + // `(exec sleep 60 &)` forks a subshell that execs `sleep 60`; the subshell + // inherits the parent's stdout fd and keeps it open. + let dir = std::env::temp_dir().join(format!("buzz-probe-descendant-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let bin = dir.join("codex-acp"); + std::fs::write( + &bin, + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\n(exec sleep 60 &)\nexit 0\n", + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let start = Instant::now(); + let major = probe_codex_acp_major_version(&bin); + let elapsed = start.elapsed(); + let _ = std::fs::remove_dir_all(dir); + + // Must return within ~1 s: non-blocking read, no waiting for descendant. + // Give a 9 s margin for parallel pre-push suites. + assert!( + elapsed.as_secs() < 10, + "probe must not block on descendant pipe; elapsed: {elapsed:?}" + ); + assert_eq!( + major, + Some(1), + "1.x version must be parsed even when descendant holds pipe open" + ); +} + +// ── parse_semver_tag ────────────────────────────────────────────────────────── + +#[test] +fn parse_semver_tag_accepts_plain_version() { + assert_eq!(parse_semver_tag("v20.11.1"), Some((20, 11, 1))); +} + +#[test] +fn parse_semver_tag_accepts_prerelease_suffix() { + assert_eq!(parse_semver_tag("v18.0.0-rc1"), Some((18, 0, 0))); +} + +#[test] +fn parse_semver_tag_rejects_missing_v_prefix() { + assert_eq!(parse_semver_tag("20.11.1"), None); +} + +#[test] +fn parse_semver_tag_rejects_non_numeric() { + assert_eq!(parse_semver_tag("vX.Y.Z"), None); +} + +#[test] +fn semver_ordering_chooses_highest() { + let mut tags = [ + parse_semver_tag("v16.0.0").unwrap(), + parse_semver_tag("v20.11.1").unwrap(), + parse_semver_tag("v18.12.0").unwrap(), + ]; + tags.sort(); + assert_eq!(tags.last(), parse_semver_tag("v20.11.1").as_ref()); +} + +// ── find_nvm_default_bin ────────────────────────────────────────────────────── + +#[cfg(unix)] +fn make_nvm_version_dir(home: &std::path::Path, tag: &str) { + let bin = home.join(".nvm/versions/node").join(tag).join("bin"); + std::fs::create_dir_all(&bin).unwrap(); +} + +#[cfg(unix)] +fn write_alias(home: &std::path::Path, name: &str, content: &str) { + let alias_dir = home.join(".nvm/alias"); + std::fs::create_dir_all(&alias_dir).unwrap(); + std::fs::write(alias_dir.join(name), content).unwrap(); +} + +#[cfg(unix)] +#[test] +fn find_nvm_default_bin_returns_dir_from_alias_default() { + let home = tempfile::tempdir().unwrap(); + make_nvm_version_dir(home.path(), "v20.11.1"); + write_alias(home.path(), "default", "v20.11.1\n"); + + let result = find_nvm_default_bin(home.path()); + assert_eq!( + result, + Some(home.path().join(".nvm/versions/node/v20.11.1/bin")) + ); +} + +#[cfg(unix)] +#[test] +fn find_nvm_default_bin_follows_one_alias_hop() { + let home = tempfile::tempdir().unwrap(); + make_nvm_version_dir(home.path(), "v18.0.0"); + // alias/default → "lts/hydrogen", alias/lts/hydrogen → "v18.0.0" + write_alias(home.path(), "default", "lts/hydrogen\n"); + let alias_dir = home.path().join(".nvm/alias/lts"); + std::fs::create_dir_all(&alias_dir).unwrap(); + std::fs::write(alias_dir.join("hydrogen"), "v18.0.0\n").unwrap(); + + let result = find_nvm_default_bin(home.path()); + assert_eq!( + result, + Some(home.path().join(".nvm/versions/node/v18.0.0/bin")) + ); +} + +#[cfg(unix)] +#[test] +fn find_nvm_default_bin_falls_back_to_highest_semver() { + let home = tempfile::tempdir().unwrap(); + make_nvm_version_dir(home.path(), "v16.0.0"); + make_nvm_version_dir(home.path(), "v20.11.1"); + make_nvm_version_dir(home.path(), "v18.12.0"); + // No alias/default file. + + let result = find_nvm_default_bin(home.path()); + assert_eq!( + result, + Some(home.path().join(".nvm/versions/node/v20.11.1/bin")) + ); +} + +#[cfg(unix)] +#[test] +fn find_nvm_default_bin_returns_none_when_nvm_absent() { + let home = tempfile::tempdir().unwrap(); + // No ~/.nvm directory. + assert_eq!(find_nvm_default_bin(home.path()), None); +} + +#[cfg(unix)] +#[test] +fn find_nvm_default_bin_returns_none_when_versions_dir_empty() { + let home = tempfile::tempdir().unwrap(); + let versions_dir = home.path().join(".nvm/versions/node"); + std::fs::create_dir_all(&versions_dir).unwrap(); + assert_eq!(find_nvm_default_bin(home.path()), None); +} + +// ── refresh_login_shell_path ────────────────────────────────────────────────── + +#[test] +fn refresh_login_shell_path_clears_cache() { + let _guard = crate::managed_agents::lock_path_mutex(); + // Populate the cache with a first call. + let before = super::login_shell_path(); + assert!( + !is_login_shell_path_uninit(), + "cache must be Probed(…) after a login_shell_path() call" + ); + + // Refresh must reset the cache to Uninit so the next call re-fetches. + refresh_login_shell_path(); + assert!( + is_login_shell_path_uninit(), + "cache must be Uninit immediately after refresh_login_shell_path()" + ); + + // Re-fetching must produce the same value (deterministic in this environment). + let after = super::login_shell_path(); + assert_eq!( + before, after, + "login_shell_path must return the same value after refresh + re-fetch" + ); + // And the cache is Probed again. + assert!( + !is_login_shell_path_uninit(), + "cache must be Probed(…) after the second login_shell_path() call" + ); +} + +// ── is_safe_nvm_tag ─────────────────────────────────────────────────────────── + +#[test] +fn is_safe_nvm_tag_accepts_version_tags() { + assert!(is_safe_nvm_tag("v20.11.1")); + assert!(is_safe_nvm_tag("v18.0.0-rc1")); + assert!(is_safe_nvm_tag("lts/hydrogen")); + assert!(is_safe_nvm_tag("v22.1.0")); +} + +#[test] +fn is_safe_nvm_tag_rejects_absolute_paths() { + assert!(!is_safe_nvm_tag("/tmp/evil")); + assert!(!is_safe_nvm_tag("/usr/local/bin")); + assert!(!is_safe_nvm_tag("/")); +} + +#[test] +fn is_safe_nvm_tag_rejects_dotdot_traversal() { + assert!(!is_safe_nvm_tag("../../../etc/passwd")); + assert!(!is_safe_nvm_tag("v20.11.1/../../../etc")); + assert!(!is_safe_nvm_tag("..")); +} + +#[test] +fn is_safe_nvm_tag_rejects_junk_charset() { + assert!(!is_safe_nvm_tag("v20.11.1; rm -rf ~")); + assert!(!is_safe_nvm_tag("v20.11.1\n/tmp/evil")); + assert!(!is_safe_nvm_tag("v20.11.1\0")); + assert!(!is_safe_nvm_tag("$(evil)")); +} + +#[test] +fn is_safe_nvm_tag_rejects_empty() { + assert!(!is_safe_nvm_tag("")); +} + +// ── find_nvm_default_bin alias security ────────────────────────────────────── + +#[cfg(unix)] +#[test] +fn find_nvm_default_bin_rejects_absolute_path_alias() { + let home = tempfile::tempdir().unwrap(); + // Alias points at an absolute path — PathBuf::join would replace the base. + write_alias(home.path(), "default", "/tmp/evil\n"); + // Must NOT return Some("/tmp/evil/bin") — must fall through to semver scan. + let result = find_nvm_default_bin(home.path()); + // No version dirs exist, so result must be None (not Some("/tmp/evil/bin")). + assert_eq!(result, None, "absolute-path alias must be rejected"); +} + +#[cfg(unix)] +#[test] +fn find_nvm_default_bin_rejects_dotdot_traversal_alias() { + let home = tempfile::tempdir().unwrap(); + write_alias(home.path(), "default", "../../etc/passwd\n"); + let result = find_nvm_default_bin(home.path()); + assert_eq!(result, None, "dotdot traversal alias must be rejected"); +} + +#[cfg(unix)] +#[test] +fn find_nvm_default_bin_rejects_absolute_hop_tag() { + let home = tempfile::tempdir().unwrap(); + // First hop points at a safe tag that resolves to another alias file which + // then contains an absolute path. + write_alias(home.path(), "default", "lts/testing\n"); + let lts_dir = home.path().join(".nvm/alias/lts"); + std::fs::create_dir_all(<s_dir).unwrap(); + std::fs::write(lts_dir.join("testing"), "/tmp/evil\n").unwrap(); + let result = find_nvm_default_bin(home.path()); + assert_eq!(result, None, "absolute-path hop tag must be rejected"); +} + +// ── Phase C: command_basenames ────────────────────────────────────────────── + +/// On non-Windows, command_basenames returns only the executable_basename. +#[cfg(not(windows))] +#[test] +fn test_command_basenames_single_candidate_on_unix() { + let candidates = super::command_basenames("codex-acp"); + assert_eq!( + candidates, + vec!["codex-acp"], + "Unix must produce a single candidate" + ); +} + +/// On Windows, command_basenames adds .exe, .cmd, and .bat candidates. +#[cfg(windows)] +#[test] +fn test_command_basenames_includes_cmd_bat_on_windows() { + let candidates = super::command_basenames("codex-acp"); + assert_eq!( + candidates, + vec![ + "codex-acp.exe".to_string(), + "codex-acp.cmd".to_string(), + "codex-acp.bat".to_string(), + ], + "Windows must produce .exe, .cmd, and .bat candidates" + ); +} + +/// command_basenames with a dotted name never adds .cmd/.bat. +#[test] +fn test_command_basenames_dotted_name_no_extra_candidates() { + let candidates = super::command_basenames("codex-acp.exe"); + // Should contain the executable_basename only, no .cmd/.bat. + assert_eq!( + candidates.len(), + 1, + "dotted name must produce exactly one candidate" + ); +} + +// ── Phase B: cli_install_commands_for_os ──────────────────────────────────── + +/// Claude and Codex have non-empty default cli_install_commands (install.sh). +#[test] +fn test_claude_and_codex_have_cli_install_commands() { + let claude = super::known_acp_runtime_exact("claude").unwrap(); + let codex = super::known_acp_runtime_exact("codex").unwrap(); + assert!( + !claude.cli_install_commands.is_empty(), + "claude must have cli install commands" + ); + assert!( + !codex.cli_install_commands.is_empty(), + "codex must have cli install commands" + ); +} + +/// cli_install_commands_for_os returns a non-empty slice for claude and codex. +#[test] +fn test_cli_install_commands_for_os_non_empty_for_claude_codex() { + let claude = super::known_acp_runtime_exact("claude").unwrap(); + let codex = super::known_acp_runtime_exact("codex").unwrap(); + assert!( + !claude.cli_install_commands_for_os().is_empty(), + "claude must have install commands on every platform" + ); + assert!( + !codex.cli_install_commands_for_os().is_empty(), + "codex must have install commands on every platform" + ); +} + +/// On Windows, Claude and Codex select the PowerShell install commands. +#[cfg(windows)] +#[test] +fn test_cli_install_commands_for_os_selects_powershell_on_windows() { + let claude = super::known_acp_runtime_exact("claude").unwrap(); + let codex = super::known_acp_runtime_exact("codex").unwrap(); + + // Windows must select the PowerShell commands, not the curl|bash ones. + let claude_cmds = claude.cli_install_commands_for_os(); + let codex_cmds = codex.cli_install_commands_for_os(); + + assert_ne!( + claude_cmds, claude.cli_install_commands, + "Windows must NOT use the default curl|bash commands for claude" + ); + assert_ne!( + codex_cmds, codex.cli_install_commands, + "Windows must NOT use the default curl|bash commands for codex" + ); + + // Verify they are the PowerShell installers. + assert!( + claude_cmds.iter().any(|c| c.contains("powershell")), + "claude Windows install must use powershell; got: {claude_cmds:?}" + ); + assert!( + codex_cmds.iter().any(|c| c.contains("powershell")), + "codex Windows install must use powershell; got: {codex_cmds:?}" + ); + + // Goose and buzz-agent must NOT use Windows-specific commands. + let goose = super::known_acp_runtime_exact("goose").unwrap(); + assert_eq!( + goose.cli_install_commands_for_os(), + goose.cli_install_commands, + "goose must use the same commands on all platforms" + ); +} + +// ── Phase C: login_shell_candidates ───────────────────────────────────────── + +/// On Unix, login_shell_candidates returns at least one candidate. +#[cfg(unix)] +#[test] +fn test_login_shell_candidates_non_empty_on_unix() { + let candidates = super::login_shell_candidates(); + assert!( + !candidates.is_empty(), + "Unix must have at least one login shell candidate" + ); + // The first candidate should be /bin/zsh or /bin/bash. + let first = &candidates[0]; + assert!( + first == std::path::Path::new("/bin/zsh") || first == std::path::Path::new("/bin/bash"), + "expected /bin/zsh or /bin/bash, got {first:?}" + ); +} + +// ── Regression: POSIX PATH must never reach native Windows consumers ─────── + +/// `login_shell_path()` must return `None` on Windows so native-process +/// consumers (`agent_models`, `build_augmented_path`, `cli_probe`) inherit +/// the real Windows PATH instead of a POSIX colon-delimited string from +/// Git Bash's `echo $PATH`. +#[cfg(windows)] +#[test] +fn test_login_shell_path_returns_none_on_windows() { + let _guard = crate::managed_agents::lock_path_mutex(); + + // Force a fresh fetch — don't rely on whatever prior tests cached. + refresh_login_shell_path(); + let path = super::login_shell_path(); + + assert_eq!( + path, None, + "login_shell_path() must return None on Windows to prevent POSIX PATH leaking into native children" + ); +} + +// ── Phase C: .cmd shim resolution on Windows ─────────────────────────────── + +/// `resolve_command_uncached` finds `.cmd` shims via the Windows PATH scan +/// (discovery.rs lines 648-657). Creates a temp dir with ONLY a `.cmd` shim +/// (no `.exe`), mutates PATH under the serialized lock, and calls the actual +/// resolver — proving the full resolution chain finds `.cmd` extensions. +#[cfg(windows)] +#[test] +fn test_cmd_shim_resolves_from_path() { + let _guard = crate::managed_agents::lock_path_mutex(); + + // Create a temp dir with only a .cmd shim — no .exe. + let temp = tempfile::tempdir().expect("tempdir"); + let shim = temp.path().join("test-shim-cmd-resolve.cmd"); + std::fs::write(&shim, "@echo off\r\n").expect("write shim"); + + // Prepend the temp dir to PATH so the resolver sees it. + // path_candidates_from_env_raw reads PATH live (std::env::var_os each call). + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let mut new_path = std::env::split_paths(&old_path).collect::>(); + new_path.insert(0, temp.path().to_path_buf()); + let joined = std::env::join_paths(&new_path).expect("join PATH"); + std::env::set_var("PATH", &joined); + + let result = super::resolve_command_uncached("test-shim-cmd-resolve"); + + // Restore PATH before any assertion can panic. + std::env::set_var("PATH", &old_path); + + assert_eq!( + result.as_deref(), + Some(shim.as_path()), + "resolve_command_uncached must find a .cmd shim on PATH when no .exe exists" + ); +} + +// ── Phase A: no-shell-resolved error on Windows ──────────────────────────── + +/// When all resolution sources are empty AND the registry is disabled, +/// `resolve_git_bash_no_registry` returns `None` deterministically — +/// regardless of the CI runner's installed software. +#[cfg(windows)] +#[test] +fn test_no_git_bash_resolved_returns_none() { + use crate::managed_agents::git_bash; + + // Empty PATH, no overrides, no well-known dirs, registry disabled. + let result = git_bash::resolve_git_bash_no_registry("", None, None, None, None, None, None); + assert_eq!( + result, None, + "empty environment with registry disabled must not resolve a Git Bash" + ); +} + +/// When `resolve_bash_path` returns `None` (no Git Bash anywhere), +/// `install_shell_from` maps it to `Err(GIT_BASH_INSTALL_HINT)` — the exact +/// Doctor hint shown to the user. Tests the pure error-mapping seam, not the +/// resolution chain. +#[cfg(windows)] +#[test] +fn test_install_shell_from_none_returns_hint() { + use crate::commands::install_shell_from; + use crate::managed_agents::git_bash; + + let result = install_shell_from(None); + assert_eq!( + result, + Err(git_bash::GIT_BASH_INSTALL_HINT.to_string()), + "install_shell_from(None) must return the Git Bash install hint" + ); +} + +/// When `resolve_bash_path` returns `Some(path)`, `install_shell_from` +/// passes it through as `Ok`. +#[cfg(windows)] +#[test] +fn test_install_shell_from_some_returns_path() { + use crate::commands::install_shell_from; + + let path = std::path::PathBuf::from(r"C:\Git\bin\bash.exe"); + let result = install_shell_from(Some(path.clone())); + assert_eq!( + result, + Ok(path), + "install_shell_from(Some) must return the path as Ok" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs new file mode 100644 index 0000000000..5886a43990 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs @@ -0,0 +1,48 @@ +use super::super::probe_codex_acp_major_version_with_path; + +#[cfg(unix)] +#[test] +fn probe_codex_acp_major_version_uses_augmented_path_for_env_shebang_interpreter() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().expect("temp dir"); + let script_dir = temp.path().join("script-bin"); + let interpreter_dir = temp.path().join("interpreter-bin"); + let empty_path_dir = temp.path().join("empty-bin"); + fs::create_dir_all(&script_dir).expect("script dir"); + fs::create_dir_all(&interpreter_dir).expect("interpreter dir"); + fs::create_dir_all(&empty_path_dir).expect("empty path dir"); + + let interpreter_path = interpreter_dir.join("node"); + fs::write( + &interpreter_path, + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\n", + ) + .expect("write interpreter"); + fs::set_permissions(&interpreter_path, fs::Permissions::from_mode(0o755)) + .expect("chmod interpreter"); + + let shim_path = script_dir.join("codex-acp"); + fs::write(&shim_path, "#!/usr/bin/env node\n").expect("write shim"); + fs::set_permissions(&shim_path, fs::Permissions::from_mode(0o755)).expect("chmod shim"); + + let scrubbed_path = std::env::join_paths([empty_path_dir.as_path()]) + .expect("join scrubbed PATH") + .to_string_lossy() + .into_owned(); + assert_eq!( + probe_codex_acp_major_version_with_path(&shim_path, Some(&scrubbed_path)), + None, + "with a scrubbed PATH, /usr/bin/env should not find node" + ); + + let augmented_path = std::env::join_paths([interpreter_dir.as_path()]) + .expect("join augmented PATH") + .to_string_lossy() + .into_owned(); + assert_eq!( + probe_codex_acp_major_version_with_path(&shim_path, Some(&augmented_path)), + Some(1), + "the injected augmented PATH should allow /usr/bin/env to find node" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 4383086c10..6318a5c838 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -7,16 +7,15 @@ //! //! A small set of *reserved* keys — Buzz's identity and secrets — are //! rejected at save time and stripped at runtime so a typo or malicious -//! value can't swap the agent's nsec. Behavior knobs (GOOSE_MODE, -//! BUZZ_TOOLSETS, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain +//! value can't swap the agent's nsec. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain //! freely overridable — those have dedicated UI fields, but power users //! may want to bypass them. use std::collections::BTreeMap; -/// Env var keys that are *derived* from the structured `PersonaRecord.provider` -/// and `PersonaRecord.model` fields at spawn/deploy time. These must NOT be -/// persisted in `PersonaRecord.env_vars` because they would shadow the +/// Env var keys that are *derived* from the structured `AgentDefinition.provider` +/// and `AgentDefinition.model` fields at spawn/deploy time. These must NOT be +/// persisted in `AgentDefinition.env_vars` because they would shadow the /// structured fields after the user edits provider/model in the UI. /// /// At local spawn time, `runtime_metadata_env_vars` re-derives these from the @@ -33,28 +32,13 @@ pub(crate) const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[ ]; /// Returns `true` if `key` is a derived provider/model env key that should be -/// filtered out of persisted `PersonaRecord.env_vars` at pack import time. +/// filtered out of persisted `AgentDefinition.env_vars` at pack import time. pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { DERIVED_PROVIDER_MODEL_ENV_KEYS .iter() .any(|k| k.eq_ignore_ascii_case(key)) } -/// Strip derived provider/model env keys from a pack persona's `runtime_env_vars` -/// before persisting them in `PersonaRecord.env_vars`. -/// -/// The structured `PersonaRecord.provider` / `PersonaRecord.model` fields are -/// the authoritative source of truth. Keeping the derived copies would cause -/// stale env values to override updated structured fields at spawn/deploy time. -pub(crate) fn filter_derived_provider_model_env_vars( - env_vars: impl IntoIterator, -) -> BTreeMap { - env_vars - .into_iter() - .filter(|(k, _)| !is_derived_provider_model_key(k)) - .collect() -} - /// Env var keys that Buzz sets itself and users must not override from /// the persona/agent env_vars UI. Three categories: /// @@ -68,8 +52,7 @@ pub(crate) fn filter_derived_provider_model_env_vars( /// example), or redirect the agent to an attacker-controlled relay. /// /// This list is deliberately narrow — it only covers keys with security -/// implications. Behavior knobs (GOOSE_MODE, BUZZ_TOOLSETS, -/// BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely +/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely /// overridable; those have dedicated UI fields but power users may want /// to bypass them. pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ @@ -94,6 +77,10 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", "BUZZ_ACP_AGENT_OWNER", + // Readiness handoff: desktop is the ONLY readiness source. A saved or + // ambient env var must not be able to forge setup mode (NotReady) on a + // Ready agent or suppress it (empty/stale payload) on a NotReady one. + "BUZZ_ACP_SETUP_PAYLOAD", ]; pub(crate) fn is_reserved_env_key(key: &str) -> bool { @@ -296,12 +283,26 @@ pub(crate) fn merged_user_env( merged } -/// Resolve live env_vars for a linked persona. +/// Look up the live env map of `persona_id` within an already-loaded persona +/// slice. Returns an empty map for standalone agents (`None`) and for links +/// to personas that no longer exist (an orphaned agent spawns from its own +/// overrides alone — same fallback the prompt/model resolution uses). +pub(crate) fn live_persona_env( + personas: &[super::types::AgentDefinition], + persona_id: Option<&str>, +) -> BTreeMap { + persona_id + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .map(|p| p.env_vars.clone()) + .unwrap_or_default() +} + +/// Resolve live env_vars for a linked persona, loading personas from disk. /// /// Returns the persona's `env_vars` map if a persona_id is provided and found; -/// returns an empty map if no persona is linked or the persona is not found. -/// Used by the provider deploy path so remote agents receive current credentials; -/// local spawn uses only the pinned `record.env_vars` for determinism. +/// returns an empty map if no persona is linked. Errors if the linked persona +/// is missing. Used by the provider deploy path, which has no pre-loaded +/// persona slice. pub(crate) fn resolve_persona_env( app: &tauri::AppHandle, persona_id: Option<&str>, diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index 3d928dbfba..cf57b12546 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -1,8 +1,8 @@ use std::collections::BTreeMap; use super::{ - display_invalid_key, filter_derived_provider_model_env_vars, is_derived_provider_model_key, - is_reserved_env_key, is_well_formed_env_key, merged_user_env, validate_user_env_keys, + display_invalid_key, is_derived_provider_model_key, is_reserved_env_key, + is_well_formed_env_key, merged_user_env, validate_user_env_keys, DERIVED_PROVIDER_MODEL_ENV_KEYS, MAX_ENV_TOTAL_BYTES, MAX_ENV_VALUE_BYTES, RESERVED_ENV_KEYS, }; @@ -133,7 +133,6 @@ fn is_reserved_recognises_full_list() { assert!(!is_reserved_env_key("GOOSE_MODE")); assert!(!is_reserved_env_key("ANTHROPIC_API_KEY")); assert!(!is_reserved_env_key("BUZZ_ACP_MODEL")); // behavior knob - assert!(!is_reserved_env_key("BUZZ_TOOLSETS")); } #[test] @@ -421,7 +420,7 @@ fn merged_env_drops_oversize_value() { // // Pack import must strip derived env keys (GOOSE_MODEL, GOOSE_PROVIDER, // BUZZ_AGENT_MODEL, BUZZ_AGENT_PROVIDER) so they don't shadow the -// structured PersonaRecord.model / PersonaRecord.provider fields after +// structured AgentDefinition.model / AgentDefinition.provider fields after // the user edits them in the UI. #[test] @@ -452,85 +451,6 @@ fn is_derived_key_does_not_match_unrelated_keys() { assert!(!is_derived_provider_model_key("PROVIDER")); } -#[test] -fn filter_derived_strips_provider_model_keys_preserves_rest() { - let input = vec![ - ("GOOSE_MODEL".to_string(), "old-model".to_string()), - ("GOOSE_PROVIDER".to_string(), "old-provider".to_string()), - ("BUZZ_AGENT_MODEL".to_string(), "gpt-4o".to_string()), - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ("GOOSE_TEMPERATURE".to_string(), "0.7".to_string()), - ("GOOSE_CONTEXT_LIMIT".to_string(), "128000".to_string()), - ("CUSTOM_KEY".to_string(), "custom-value".to_string()), - ]; - - let filtered = filter_derived_provider_model_env_vars(input); - - // Derived keys must be gone. - assert!(!filtered.contains_key("GOOSE_MODEL")); - assert!(!filtered.contains_key("GOOSE_PROVIDER")); - assert!(!filtered.contains_key("BUZZ_AGENT_MODEL")); - assert!(!filtered.contains_key("BUZZ_AGENT_PROVIDER")); - - // Non-derived keys must survive. - assert_eq!( - filtered.get("GOOSE_TEMPERATURE").map(String::as_str), - Some("0.7") - ); - assert_eq!( - filtered.get("GOOSE_CONTEXT_LIMIT").map(String::as_str), - Some("128000") - ); - assert_eq!( - filtered.get("CUSTOM_KEY").map(String::as_str), - Some("custom-value") - ); -} - -#[test] -fn filter_derived_empty_input_returns_empty() { - let filtered = filter_derived_provider_model_env_vars(std::iter::empty()); - assert!(filtered.is_empty()); -} - -#[test] -fn stale_derived_env_does_not_override_structured_fields() { - // Scenario: A persona was imported WITH stale derived keys (pre-fix). - // At merge time, `merged_user_env` passes them through (it doesn't filter). - // The fix is at *import* time — this test documents that merged_user_env - // is transparent, and the import filter is the correct defense. - let stale_persona_env = map(&[ - ("BUZZ_AGENT_MODEL", "stale-model"), - ("BUZZ_AGENT_PROVIDER", "stale-provider"), - ("GOOSE_TEMPERATURE", "0.5"), - ]); - let agent_env = BTreeMap::new(); - - let merged = merged_user_env(&stale_persona_env, &agent_env); - - // merged_user_env is transparent — stale keys pass through. - assert_eq!( - merged.get("BUZZ_AGENT_MODEL").map(String::as_str), - Some("stale-model") - ); - assert_eq!( - merged.get("BUZZ_AGENT_PROVIDER").map(String::as_str), - Some("stale-provider") - ); - - // But the import filter WOULD have caught them: - let would_be_filtered = filter_derived_provider_model_env_vars(stale_persona_env); - assert!(!would_be_filtered.contains_key("BUZZ_AGENT_MODEL")); - assert!(!would_be_filtered.contains_key("BUZZ_AGENT_PROVIDER")); - // Non-derived keys survive the filter. - assert_eq!( - would_be_filtered - .get("GOOSE_TEMPERATURE") - .map(String::as_str), - Some("0.5") - ); -} - // ── deploy payload model precedence ──────────────────────────────── /// Documents the model precedence rule used by `build_deploy_payload`: diff --git a/desktop/src-tauri/src/managed_agents/git_bash.rs b/desktop/src-tauri/src/managed_agents/git_bash.rs new file mode 100644 index 0000000000..790f18cfa2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/git_bash.rs @@ -0,0 +1,579 @@ +//! Git Bash discovery shared by Doctor and the buzz-agent readiness gate. +//! +//! The MCP child receives a deliberately small environment. Discovery inspects +//! exactly the shared resolver-key contract forwarded into that child, plus the +//! Git-for-Windows registry. A Doctor green state therefore means `buzz-dev-mcp` +//! can actually start its shell. + +#[cfg(windows)] +use std::path::{Path, PathBuf}; + +/// A Git Bash installation the stripped MCP child can launch. +#[derive(Debug, Clone, serde::Serialize)] +pub(crate) struct GitBashPrerequisite { + pub available: bool, + pub path: Option, + pub install_instructions_url: String, + pub install_hint: String, +} + +#[cfg(windows)] +const INSTALL_URL: &str = "https://git-scm.com/download/win"; +#[cfg(windows)] +const INSTALL_HINT: &str = + "Install Git for Windows and select \"Git from the command line and also from 3rd-party software\" for its PATH option."; + +/// Install hint for error messages when `install_shell_command` can't find a shell on Windows. +#[cfg(windows)] +pub(crate) const GIT_BASH_INSTALL_HINT: &str = INSTALL_HINT; + +/// Resolve the Git Bash executable path using the same resolver chain as Doctor. +/// +/// Returns `Some(path)` on Windows when a usable bash is found, `None` otherwise +/// (including all non-Windows platforms). Honors `BUZZ_SHELL` (any executable) — +/// correct for the Doctor readiness gate where any shell suffices. +#[allow(dead_code)] // used only on Windows; called by discover_git_bash() +pub(crate) fn resolve_git_bash_path() -> Option { + #[cfg(windows)] + { + let env = GitBashEnv::from_process(); + return resolve_git_bash( + &env.path, + env.shell_override, + env.git_bash_override, + env.system_root, + env.program_files, + env.program_files_x86, + env.local_app_data, + ); + } + + #[cfg(not(windows))] + None +} + +/// Resolve a bash-compatible shell for install commands and login-shell discovery. +/// +/// Unlike `resolve_git_bash_path`, this skips `BUZZ_SHELL` entirely — that override +/// intentionally accepts any executable (`cmd`, `pwsh`) for the MCP child, but install +/// commands and `login_shell_candidates` use bash-only `-l -c` syntax. Skipping the +/// override means the chain falls through to: `GIT_BASH` → PATH scan → derive-from-git +/// → well-known locations → registry. +#[allow(dead_code)] // used only on Windows, from install_shell_command + login_shell_candidates +pub(crate) fn resolve_bash_path() -> Option { + #[cfg(windows)] + { + let env = GitBashEnv::from_process(); + return resolve_git_bash( + &env.path, + None, // skip BUZZ_SHELL — install/login-shell callers require bash + env.git_bash_override, + env.system_root, + env.program_files, + env.program_files_x86, + env.local_app_data, + ); + } + + #[cfg(not(windows))] + None +} + +pub(crate) fn discover_git_bash() -> Option { + #[cfg(windows)] + { + let path = resolve_git_bash_path(); + return Some(GitBashPrerequisite { + available: path.is_some(), + path: path.map(|path| path.display().to_string()), + install_instructions_url: INSTALL_URL.to_string(), + install_hint: INSTALL_HINT.to_string(), + }); + } + + #[cfg(not(windows))] + None +} + +#[cfg(windows)] +pub(crate) fn git_bash_available(overrides: &std::collections::BTreeMap) -> bool { + let env = GitBashEnv::from_process_with_overrides(overrides); + resolve_git_bash( + &env.path, + env.shell_override, + env.git_bash_override, + env.system_root, + env.program_files, + env.program_files_x86, + env.local_app_data, + ) + .is_some() +} + +/// All process environment that Git Bash discovery may inspect. Its keys are +/// deliberately sourced from `buzz_agent_pkg::WINDOWS_SHELL_RESOLUTION_ENV`, +/// the exact allowlist forwarded to the otherwise-cleared MCP child. +#[cfg(windows)] +struct GitBashEnv { + path: String, + shell_override: Option, + git_bash_override: Option, + system_root: Option, + program_files: Option, + program_files_x86: Option, + local_app_data: Option, +} + +#[cfg(windows)] +impl GitBashEnv { + fn from_process() -> Self { + Self::from_process_with_overrides(&Default::default()) + } + + fn from_process_with_overrides(overrides: &std::collections::BTreeMap) -> Self { + let values: std::collections::HashMap<_, _> = buzz_agent_pkg::WINDOWS_SHELL_RESOLUTION_ENV + .iter() + .filter_map(|key| { + overrides + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key)) + .map(|(_, value)| std::ffi::OsString::from(value)) + .or_else(|| std::env::var_os(key)) + .map(|value| (*key, value)) + }) + .collect(); + Self::from_lookup(|key| values.get(key).cloned()) + } + + fn from_lookup(mut get: impl FnMut(&str) -> Option) -> Self { + Self { + path: get("PATH") + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + shell_override: get("BUZZ_SHELL").map(PathBuf::from), + git_bash_override: get("GIT_BASH").map(PathBuf::from), + system_root: get("SystemRoot").map(PathBuf::from), + program_files: get("ProgramFiles").map(PathBuf::from), + program_files_x86: get("ProgramFiles(x86)").map(PathBuf::from), + local_app_data: get("LOCALAPPDATA").map(PathBuf::from), + } + } +} + +#[cfg(windows)] +pub(crate) fn resolve_git_bash( + path_env: &str, + shell_override: Option, + git_bash_override: Option, + system_root: Option, + program_files: Option, + program_files_x86: Option, + local_app_data: Option, +) -> Option { + resolve_git_bash_inner( + path_env, + shell_override, + git_bash_override, + system_root, + program_files, + program_files_x86, + local_app_data, + true, + ) +} + +/// Inner resolver with an explicit `check_registry` toggle so tests can +/// disable the ambient `HKLM/HKCU\SOFTWARE\GitForWindows` lookup. +#[cfg(windows)] +fn resolve_git_bash_inner( + path_env: &str, + shell_override: Option, + git_bash_override: Option, + system_root: Option, + program_files: Option, + program_files_x86: Option, + local_app_data: Option, + check_registry: bool, +) -> Option { + let result = shell_override + .and_then(|path| resolve_shell_override(&path, path_env)) + .or_else(|| git_bash_override.filter(|path| path.is_file())) + .or_else(|| scan_path_for_bash(path_env, system_root.as_deref())) + .or_else(|| { + scan_path_for_command(Path::new("git.exe"), path_env, None) + .and_then(|git| bash_from_git(&git)) + }) + .or_else(|| { + git_bash_from_standard_paths([program_files, program_files_x86, local_app_data]) + }); + if result.is_some() { + return result; + } + if check_registry { + return git_bash_from_registry(); + } + None +} + +/// Like `resolve_git_bash` but skips the ambient Windows registry lookup, so +/// tests can assert "no resolution" deterministically regardless of the CI +/// runner's installed software. +#[cfg(all(windows, test))] +pub(crate) fn resolve_git_bash_no_registry( + path_env: &str, + shell_override: Option, + git_bash_override: Option, + system_root: Option, + program_files: Option, + program_files_x86: Option, + local_app_data: Option, +) -> Option { + resolve_git_bash_inner( + path_env, + shell_override, + git_bash_override, + system_root, + program_files, + program_files_x86, + local_app_data, + false, + ) +} + +/// Resolve `BUZZ_SHELL` with the same rooted/bare-name semantics as the MCP +/// resolver. This intentionally accepts any executable shell; its presence is +/// sufficient for the MCP child and therefore for the readiness gate. +#[cfg(windows)] +fn resolve_shell_override(shell: &Path, path_env: &str) -> Option { + if shell.components().count() > 1 || shell.has_root() { + shell.is_file().then(|| shell.to_path_buf()) + } else { + scan_path_for_command(shell, path_env, None) + } +} + +#[cfg(windows)] +fn bash_from_git(git: &Path) -> Option { + let bash = git.parent()?.parent()?.join("bin").join("bash.exe"); + bash.is_file().then_some(bash) +} + +#[cfg(windows)] +fn scan_path_for_bash(path_env: &str, system_root: Option<&Path>) -> Option { + scan_path_for_command(Path::new("bash.exe"), path_env, system_root) +} + +#[cfg(windows)] +fn scan_path_for_command( + name: &Path, + path_env: &str, + system_root: Option<&Path>, +) -> Option { + let needs_exe = name.extension().is_none(); + std::env::split_paths(path_env).find_map(|dir| { + if system_root.is_some_and(|root| is_under_dir(&dir, root)) { + return None; + } + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + if needs_exe { + let mut candidate = dir.join(name); + candidate.set_extension("exe"); + if candidate.is_file() { + return Some(candidate); + } + } + None + }) +} + +#[cfg(windows)] +fn is_under_dir(dir: &Path, root: &Path) -> bool { + let mut dir_components = dir.components(); + root.components().all(|root_component| { + dir_components.next().is_some_and(|dir_component| { + dir_component + .as_os_str() + .eq_ignore_ascii_case(root_component.as_os_str()) + }) + }) +} + +/// Probe machine and per-user Git for Windows registry keys after the standard +/// install-location fallback has been exhausted. +#[cfg(windows)] +fn git_bash_from_registry() -> Option { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS}; + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, + KEY_READ, + }; + + const KEY: &str = "SOFTWARE\\GitForWindows"; + const VALUE: &str = "InstallPath"; + let key: Vec = KEY.encode_utf16().chain(Some(0)).collect(); + let value: Vec = VALUE.encode_utf16().chain(Some(0)).collect(); + + // SAFETY: Inputs are null-terminated UTF-16 for the duration of each call, + // and every successfully opened handle is closed before trying the next hive. + unsafe { + for hive in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] { + let mut handle = std::ptr::null_mut(); + if RegOpenKeyExW(hive, key.as_ptr(), 0, KEY_READ, &mut handle) != ERROR_SUCCESS { + continue; + } + + let mut byte_len = 0; + let status = RegQueryValueExW( + handle, + value.as_ptr(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut byte_len, + ); + if (status != ERROR_SUCCESS && status != ERROR_MORE_DATA) || byte_len == 0 { + RegCloseKey(handle); + continue; + } + + let mut data = vec![0u16; (byte_len as usize).div_ceil(2)]; + let status = RegQueryValueExW( + handle, + value.as_ptr(), + std::ptr::null(), + std::ptr::null_mut(), + data.as_mut_ptr().cast(), + &mut byte_len, + ); + RegCloseKey(handle); + if status != ERROR_SUCCESS { + continue; + } + + while data.last() == Some(&0) { + data.pop(); + } + let bash = PathBuf::from(OsString::from_wide(&data)) + .join("bin") + .join("bash.exe"); + if bash.is_file() { + return Some(bash); + } + } + } + + None +} + +#[cfg(windows)] +fn git_bash_from_standard_paths( + [program_files, program_files_x86, local_app_data]: [Option; 3], +) -> Option { + [ + program_files.map(|base| base.join("Git")), + program_files_x86.map(|base| base.join("Git")), + local_app_data.map(|base| base.join("Programs").join("Git")), + ] + .into_iter() + .flatten() + .map(|install_root| install_root.join("bin").join("bash.exe")) + .find(|bash| bash.is_file()) +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use tempfile::tempdir; + + const DETECTOR_ENV_KEYS: &[&str] = &[ + "PATH", + "BUZZ_SHELL", + "GIT_BASH", + "SystemRoot", + "ProgramFiles", + "ProgramFiles(x86)", + "LOCALAPPDATA", + ]; + + #[test] + fn test_detector_env_keys_match_agent_shell_resolution_contract() { + assert_eq!( + DETECTOR_ENV_KEYS, + buzz_agent_pkg::WINDOWS_SHELL_RESOLUTION_ENV, + "Doctor and the env-cleared MCP child must inspect the same resolver inputs" + ); + + let env = GitBashEnv::from_lookup(|key| Some(key.into())); + assert_eq!(env.path, "PATH"); + assert_eq!(env.shell_override, Some(PathBuf::from("BUZZ_SHELL"))); + assert_eq!(env.git_bash_override, Some(PathBuf::from("GIT_BASH"))); + assert_eq!(env.system_root, Some(PathBuf::from("SystemRoot"))); + assert_eq!(env.program_files, Some(PathBuf::from("ProgramFiles"))); + assert_eq!( + env.program_files_x86, + Some(PathBuf::from("ProgramFiles(x86)")) + ); + assert_eq!(env.local_app_data, Some(PathBuf::from("LOCALAPPDATA"))); + } + + #[test] + fn test_git_cmd_on_path_resolves_sibling_bash() { + let temp = tempdir().expect("tempdir"); + let git = temp + .path() + .join("Program Files") + .join("Git") + .join("cmd") + .join("git.exe"); + let bash = temp + .path() + .join("Program Files") + .join("Git") + .join("bin") + .join("bash.exe"); + std::fs::create_dir_all(git.parent().expect("git parent")).expect("mkdir git"); + std::fs::create_dir_all(bash.parent().expect("bash parent")).expect("mkdir bash"); + std::fs::write(&git, []).expect("git"); + std::fs::write(&bash, []).expect("bash"); + + let path = std::env::join_paths([git.parent().expect("cmd dir")]).expect("PATH"); + assert_eq!( + resolve_git_bash( + path.to_str().expect("utf8"), + None, + None, + None, + None, + None, + None + ), + Some(bash) + ); + } + + #[test] + fn test_program_files_x86_git_bash_resolves() { + let temp = tempdir().expect("tempdir"); + let program_files_x86 = temp.path().join("Program Files (x86)"); + let bash = program_files_x86.join("Git").join("bin").join("bash.exe"); + std::fs::create_dir_all(bash.parent().expect("bash parent")).expect("mkdir bash"); + std::fs::write(&bash, []).expect("bash"); + + assert_eq!( + resolve_git_bash("", None, None, None, None, Some(program_files_x86), None,), + Some(bash) + ); + } + + #[test] + fn test_effective_buzz_shell_override_marks_agent_ready() { + let temp = tempdir().expect("tempdir"); + let shell = temp.path().join("pwsh.exe"); + std::fs::write(&shell, []).expect("shell"); + + let mut overrides = std::collections::BTreeMap::new(); + overrides.insert("buzz_shell".to_string(), shell.display().to_string()); + let env = GitBashEnv::from_process_with_overrides(&overrides); + assert_eq!(env.shell_override, Some(shell.clone())); + assert_eq!( + resolve_git_bash( + &env.path, + env.shell_override, + env.git_bash_override, + env.system_root, + env.program_files, + env.program_files_x86, + env.local_app_data, + ), + Some(shell) + ); + } + + #[test] + fn test_buzz_shell_override_wins_over_git_bash_discovery() { + let temp = tempdir().expect("tempdir"); + let shell = temp.path().join("pwsh.exe"); + let bash = temp.path().join("bash.exe"); + std::fs::write(&shell, []).expect("shell"); + std::fs::write(&bash, []).expect("bash"); + + let path = std::env::join_paths([temp.path()]).expect("PATH"); + assert_eq!( + resolve_git_bash( + path.to_str().expect("utf8"), + Some(shell.clone()), + Some(bash), + None, + None, + None, + None, + ), + Some(shell) + ); + } + + // ── Regression: install/login-shell must skip non-bash BUZZ_SHELL ───────── + + /// When BUZZ_SHELL=pwsh.exe, `resolve_git_bash` with `shell_override=None` + /// (the `resolve_bash_path` code path) skips it and falls through to the + /// bash.exe on PATH. The readiness gate (`shell_override=Some`) still + /// returns pwsh — both contracts hold simultaneously. + #[test] + fn test_install_path_skips_buzz_shell_pwsh() { + let temp = tempdir().expect("tempdir"); + let pwsh = temp.path().join("pwsh.exe"); + let bash = temp.path().join("bash.exe"); + std::fs::write(&pwsh, []).expect("pwsh"); + std::fs::write(&bash, []).expect("bash"); + + let path = std::env::join_paths([temp.path()]).expect("PATH"); + let path_str = path.to_str().expect("utf8"); + + // Readiness gate: BUZZ_SHELL=pwsh accepted (Doctor green). + assert_eq!( + resolve_git_bash(path_str, Some(pwsh.clone()), None, None, None, None, None), + Some(pwsh), + "readiness gate must accept BUZZ_SHELL=pwsh" + ); + + // Install path: shell_override=None skips pwsh, finds bash on PATH. + assert_eq!( + resolve_git_bash(path_str, None, None, None, None, None, None), + Some(bash), + "install path must skip BUZZ_SHELL and find bash on PATH" + ); + } + + /// Same as above but with BUZZ_SHELL=cmd.exe. + #[test] + fn test_install_path_skips_buzz_shell_cmd() { + let temp = tempdir().expect("tempdir"); + let cmd = temp.path().join("cmd.exe"); + let bash = temp.path().join("bash.exe"); + std::fs::write(&cmd, []).expect("cmd"); + std::fs::write(&bash, []).expect("bash"); + + let path = std::env::join_paths([temp.path()]).expect("PATH"); + let path_str = path.to_str().expect("utf8"); + + // Readiness gate: BUZZ_SHELL=cmd accepted. + assert_eq!( + resolve_git_bash(path_str, Some(cmd.clone()), None, None, None, None, None), + Some(cmd), + "readiness gate must accept BUZZ_SHELL=cmd" + ); + + // Install path: shell_override=None skips cmd, finds bash on PATH. + assert_eq!( + resolve_git_bash(path_str, None, None, None, None, None, None), + Some(bash), + "install path must skip BUZZ_SHELL and find bash on PATH" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs new file mode 100644 index 0000000000..cf8629f828 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -0,0 +1,244 @@ +//! Global agent configuration defaults. +//! +//! A single `global-agent-config.json` record that applies to ALL managed +//! agents. Per-agent config always wins; global provides the lowest +//! user-settable layer below persona. +//! +//! # Precedence (low → high) +//! +//! ```text +//! baked build env < GLOBAL < persona < per-agent < Buzz-identity +//! ``` +//! +//! # Semantics +//! +//! Unlike per-agent/persona env (snapshotted at create time), global config is +//! **live-resolved at spawn/readiness/deploy** — change a global key and every +//! agent picks it up on the next restart, with no delete+respawn required. +//! +//! # Storage +//! +//! `/agents/global-agent-config.json`, written `0o600` via +//! `atomic_write_json_restricted` (same as the agent store). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +use crate::managed_agents::env_vars::{ + validate_user_env_keys, DERIVED_PROVIDER_MODEL_ENV_KEYS, MAX_ENV_VALUE_BYTES, +}; +use crate::managed_agents::storage::{atomic_write_json_restricted, managed_agents_base_dir}; +use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; + +/// The global agent configuration record. +/// +/// Shape mirrors the per-agent/persona trio (`env_vars` + `provider` + `model`) +/// so the config vocabulary is consistent across all three tiers. +/// +/// `env_vars` is the lowest user-settable env layer — global < persona < agent. +/// `provider` / `model` are fallback defaults: effective provider/model = +/// `agent → persona → global → None`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GlobalAgentConfig { + /// Global env vars injected into ALL agents unconditionally. + /// + /// Lowest user-settable layer — per-agent and persona values win on any + /// key collision. Reserved and derived keys are rejected at save time and + /// stripped at spawn time. + #[serde(default)] + pub env_vars: BTreeMap, + + /// Global fallback provider (e.g. `"databricks_v2"`, `"anthropic"`). + /// + /// Used only when neither the agent record nor the linked persona specifies + /// a provider. `None` = no global default. + #[serde(default)] + pub provider: Option, + + /// Global fallback model identifier. + /// + /// Used only when neither the agent record nor the linked persona specifies + /// a model. `None` = no global default. + #[serde(default)] + pub model: Option, +} + +/// Validate a `GlobalAgentConfig` before persisting it. +/// +/// Rules beyond `validate_user_env_keys`: +/// - `DERIVED_PROVIDER_MODEL_ENV_KEYS` (`GOOSE_PROVIDER`, `GOOSE_MODEL`, …) +/// must NOT be set as global env vars — they would shadow the structured +/// `provider`/`model` fields and break provider/model resolution. Users +/// must use the structured fields instead. +/// - Empty per-key values are stripped before validation so a caller that +/// passes `KEY=""` does not accidentally shadow a real global value. +/// +/// `provider` and `model` rules (applied to `Some` values only): +/// - Interior NUL bytes are rejected (they truncate C-string env injection). +/// - Values exceeding [`MAX_ENV_VALUE_BYTES`] are rejected. +/// - Blank / whitespace-only values are normalized to `None` by +/// [`normalize_global_config_fields`], which must be called before +/// persisting (done inside [`save_global_agent_config`]). +pub fn validate_global_config(config: &GlobalAgentConfig) -> Result<(), String> { + // Strip empty values first — they mean "inherit" and must not be stored. + let non_empty: BTreeMap = config + .env_vars + .iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + // Standard env-var key validation (POSIX shape, reserved-key check, NUL/size caps). + validate_user_env_keys(&non_empty)?; + + // Reject derived provider/model keys in global env_vars. + let derived: Vec<&str> = non_empty + .keys() + .filter(|k| { + DERIVED_PROVIDER_MODEL_ENV_KEYS + .iter() + .any(|d| d.eq_ignore_ascii_case(k.as_str())) + }) + .map(String::as_str) + .collect(); + if !derived.is_empty() { + return Err(format!( + "the following keys must be set via the structured provider/model fields, \ + not as env vars: {}", + derived.join(", ") + )); + } + + // Validate the structured provider and model fields. + for (field, value) in [("provider", &config.provider), ("model", &config.model)] { + if let Some(v) = value { + // Reject interior NUL bytes — they truncate C-string env injection. + if v.contains('\0') { + return Err(format!( + "global config `{field}` must not contain NUL bytes" + )); + } + // Size cap: match the per-value env-var cap. + if v.len() > MAX_ENV_VALUE_BYTES { + return Err(format!( + "global config `{field}` exceeds the maximum allowed length \ + ({} bytes)", + MAX_ENV_VALUE_BYTES + )); + } + // Note: blank/whitespace-only values are normalized to None by + // normalize_global_config_fields, called from save_global_agent_config. + } + } + + Ok(()) +} + +/// Strip empty values from `env_vars`. +/// +/// Empty per-agent/persona values mean "no value"; if stored they would shadow +/// a real global default with an empty string. Strip them at save time so a +/// caller that clears a row cannot accidentally shadow global. +pub fn strip_empty_env_vars(config: &mut GlobalAgentConfig) { + config.env_vars.retain(|_, v| !v.is_empty()); +} + +/// Normalize `provider` and `model` to `None` when blank or whitespace-only. +/// +/// `Some("")` and `Some(" ")` have no meaningful value and break +/// unset/fallback semantics (a blank provider would be treated as "provider +/// explicitly set to nothing" rather than "inherit"). Normalizing to `None` +/// preserves the invariant that `Some(s)` always contains a non-blank string. +/// +/// Called from [`save_global_agent_config`] so normalization is applied at +/// every persist boundary. +pub fn normalize_global_config_fields(config: &mut GlobalAgentConfig) { + if let Some(v) = &config.provider { + if v.trim().is_empty() { + config.provider = None; + } + } + if let Some(v) = &config.model { + if v.trim().is_empty() { + config.model = None; + } + } +} + +fn global_config_path(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join("global-agent-config.json")) +} + +/// Load the global agent config from disk. +/// +/// Returns the default (all-empty) config if the file does not exist yet. +pub fn load_global_agent_config(app: &AppHandle) -> Result { + let path = global_config_path(app)?; + if !path.exists() { + return Ok(GlobalAgentConfig::default()); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("failed to read global agent config: {e}"))?; + serde_json::from_str(&content).map_err(|e| format!("failed to parse global agent config: {e}")) +} + +/// Save the global agent config to disk. +/// +/// Strips empty env values and normalizes blank provider/model to `None` +/// before writing (empty = "inherit" semantics). +/// Written `0o600` — same protection as `managed-agents.json`. +pub fn save_global_agent_config(app: &AppHandle, config: &GlobalAgentConfig) -> Result<(), String> { + let mut config = config.clone(); + strip_empty_env_vars(&mut config); + normalize_global_config_fields(&mut config); + + let path = global_config_path(app)?; + let payload = serde_json::to_vec_pretty(&config) + .map_err(|e| format!("failed to serialize global agent config: {e}"))?; + atomic_write_json_restricted(&path, &payload) +} + +/// Resolve the effective model and provider for an agent using the +/// precedence chain: `agent record → linked persona → global defaults → None`. +/// +/// This is the single source of truth used by readiness evaluation, spawn, +/// and deploy-payload construction. All three paths must use this function so +/// they agree on what model/provider the agent will actually run with. +/// +/// # Arguments +/// * `record` — the `ManagedAgentRecord` (may have `None` for model/provider) +/// * `personas` — all current persona records (looked up by `record.persona_id`) +/// * `global` — global agent config defaults +/// +/// # Returns +/// `(effective_model, effective_provider)` — both `Option<&str>`. +pub(crate) fn resolve_effective_model_provider<'a>( + record: &'a ManagedAgentRecord, + personas: &'a [AgentDefinition], + global: &'a GlobalAgentConfig, +) -> (Option<&'a str>, Option<&'a str>) { + let (persona_model, persona_provider) = record + .persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .map(|p| (p.model.as_deref(), p.provider.as_deref())) + .unwrap_or((None, None)); + + let effective_model = record + .model + .as_deref() + .or(persona_model) + .or(global.model.as_deref()); + let effective_provider = record + .provider + .as_deref() + .or(persona_provider) + .or(global.provider.as_deref()); + + (effective_model, effective_provider) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs new file mode 100644 index 0000000000..2d9b544184 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -0,0 +1,639 @@ +use std::collections::BTreeMap; + +use super::{ + normalize_global_config_fields, resolve_effective_model_provider, strip_empty_env_vars, + validate_global_config, GlobalAgentConfig, +}; +use crate::managed_agents::{AgentDefinition, BackendKind, ManagedAgentRecord, RespondTo}; + +fn config_with_env(pairs: &[(&str, &str)]) -> GlobalAgentConfig { + GlobalAgentConfig { + env_vars: pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ..Default::default() + } +} + +// ── validate_global_config ──────────────────────────────────────────────────── + +#[test] +fn validate_accepts_valid_env_vars() { + let config = config_with_env(&[("ANTHROPIC_API_KEY", "sk-test"), ("MY_CUSTOM_KEY", "value")]); + assert!(validate_global_config(&config).is_ok()); +} + +#[test] +fn validate_rejects_reserved_key() { + let config = config_with_env(&[("BUZZ_PRIVATE_KEY", "should-not-be-settable")]); + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("reserved"), + "expected reserved-key error, got: {err}" + ); +} + +#[test] +fn validate_rejects_derived_provider_model_key_goose_provider() { + let config = config_with_env(&[("GOOSE_PROVIDER", "anthropic")]); + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("structured provider/model fields"), + "expected derived-key error, got: {err}" + ); +} + +#[test] +fn validate_rejects_derived_key_goose_model() { + let config = config_with_env(&[("GOOSE_MODEL", "claude-opus-4")]); + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("structured provider/model fields"), + "got: {err}" + ); +} + +#[test] +fn validate_rejects_derived_key_buzz_agent_provider() { + let config = config_with_env(&[("BUZZ_AGENT_PROVIDER", "anthropic")]); + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("structured provider/model fields"), + "got: {err}" + ); +} + +#[test] +fn validate_rejects_malformed_key() { + let config = config_with_env(&[("has spaces", "val")]); + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("must match"), + "expected malformed-key error, got: {err}" + ); +} + +#[test] +fn validate_ignores_empty_values_for_reserved_key_check() { + // A reserved key with an EMPTY value is a no-op (stripped at save time). + // validate_global_config skips empty-value entries so it does not reject + // an empty clear for a key that happens to share a name with a reserved key. + let config = config_with_env(&[("BUZZ_PRIVATE_KEY", "")]); + // Strip is done inside validate — empty values are stripped before checking. + assert!( + validate_global_config(&config).is_ok(), + "empty value for reserved key should be treated as unset" + ); +} + +// ── validate_global_config: provider/model field rules ─────────────────────── + +#[test] +fn validate_rejects_provider_with_nul_byte() { + let config = GlobalAgentConfig { + provider: Some("anthropic\0evil".to_string()), + ..Default::default() + }; + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("provider") && err.contains("NUL"), + "expected NUL-byte error for provider, got: {err}" + ); +} + +#[test] +fn validate_rejects_model_with_nul_byte() { + let config = GlobalAgentConfig { + model: Some("claude-opus-4\0evil".to_string()), + ..Default::default() + }; + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("model") && err.contains("NUL"), + "expected NUL-byte error for model, got: {err}" + ); +} + +#[test] +fn validate_rejects_provider_exceeding_size_cap() { + use crate::managed_agents::env_vars::MAX_ENV_VALUE_BYTES; + let config = GlobalAgentConfig { + provider: Some("x".repeat(MAX_ENV_VALUE_BYTES + 1)), + ..Default::default() + }; + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("provider") && err.contains("maximum allowed length"), + "expected size-cap error for provider, got: {err}" + ); +} + +#[test] +fn validate_rejects_model_exceeding_size_cap() { + use crate::managed_agents::env_vars::MAX_ENV_VALUE_BYTES; + let config = GlobalAgentConfig { + model: Some("x".repeat(MAX_ENV_VALUE_BYTES + 1)), + ..Default::default() + }; + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("model") && err.contains("maximum allowed length"), + "expected size-cap error for model, got: {err}" + ); +} + +#[test] +fn validate_accepts_valid_provider_and_model() { + let config = GlobalAgentConfig { + provider: Some("anthropic".to_string()), + model: Some("claude-opus-4-5".to_string()), + ..Default::default() + }; + assert!(validate_global_config(&config).is_ok()); +} + +// ── normalize_global_config_fields ─────────────────────────────────────────── + +#[test] +fn normalize_some_empty_provider_becomes_none() { + let mut config = GlobalAgentConfig { + provider: Some("".to_string()), + ..Default::default() + }; + normalize_global_config_fields(&mut config); + assert!( + config.provider.is_none(), + "Some(\"\") provider must be normalized to None" + ); +} + +#[test] +fn normalize_whitespace_only_provider_becomes_none() { + let mut config = GlobalAgentConfig { + provider: Some(" ".to_string()), + ..Default::default() + }; + normalize_global_config_fields(&mut config); + assert!( + config.provider.is_none(), + "whitespace-only provider must be normalized to None" + ); +} + +#[test] +fn normalize_some_empty_model_becomes_none() { + let mut config = GlobalAgentConfig { + model: Some("".to_string()), + ..Default::default() + }; + normalize_global_config_fields(&mut config); + assert!( + config.model.is_none(), + "Some(\"\") model must be normalized to None" + ); +} + +#[test] +fn normalize_whitespace_only_model_becomes_none() { + let mut config = GlobalAgentConfig { + model: Some(" \t ".to_string()), + ..Default::default() + }; + normalize_global_config_fields(&mut config); + assert!( + config.model.is_none(), + "whitespace-only model must be normalized to None" + ); +} + +#[test] +fn normalize_valid_provider_and_model_unchanged() { + let mut config = GlobalAgentConfig { + provider: Some("anthropic".to_string()), + model: Some("claude-opus-4-5".to_string()), + ..Default::default() + }; + normalize_global_config_fields(&mut config); + assert_eq!(config.provider.as_deref(), Some("anthropic")); + assert_eq!(config.model.as_deref(), Some("claude-opus-4-5")); +} + +#[test] +fn normalize_none_fields_stay_none() { + let mut config = GlobalAgentConfig::default(); + normalize_global_config_fields(&mut config); + assert!(config.provider.is_none()); + assert!(config.model.is_none()); +} + +// ── strip_empty_env_vars ────────────────────────────────────────────────────── + +#[test] +fn strip_removes_empty_values_only() { + let mut config = config_with_env(&[("KEY_A", "value"), ("KEY_B", ""), ("KEY_C", "other")]); + strip_empty_env_vars(&mut config); + assert_eq!(config.env_vars.len(), 2); + assert!(config.env_vars.contains_key("KEY_A")); + assert!( + !config.env_vars.contains_key("KEY_B"), + "empty value must be stripped" + ); + assert!(config.env_vars.contains_key("KEY_C")); +} + +#[test] +fn strip_is_idempotent_on_all_non_empty() { + let mut config = config_with_env(&[("KEY_A", "v1"), ("KEY_B", "v2")]); + let original = config.env_vars.clone(); + strip_empty_env_vars(&mut config); + assert_eq!(config.env_vars, original); +} + +// ── GlobalAgentConfig defaults ──────────────────────────────────────────────── + +#[test] +fn default_config_is_all_none_empty() { + let config = GlobalAgentConfig::default(); + assert!(config.env_vars.is_empty()); + assert!(config.provider.is_none()); + assert!(config.model.is_none()); +} + +#[test] +fn roundtrip_serialization() { + let config = GlobalAgentConfig { + env_vars: BTreeMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())]), + provider: Some("anthropic".to_string()), + model: Some("claude-opus-4".to_string()), + }; + let json = serde_json::to_string(&config).expect("serialize"); + let back: GlobalAgentConfig = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(config, back); +} + +#[test] +fn default_global_config_serializes_all_fields() { + // IPC contract: the frontend TS type declares env_vars/provider/model as + // non-optional. A bare `{}` (old skip_serializing_if behaviour) caused an + // `Object.entries` crash on the undefined value. All three fields must + // always be present in the serialized form. + let config = GlobalAgentConfig::default(); + let json = serde_json::to_string(&config).expect("serialize"); + assert!( + json.contains("\"env_vars\""), + "serialized JSON must always include env_vars; got: {json}" + ); + assert!( + json.contains("\"provider\""), + "serialized JSON must always include provider; got: {json}" + ); + assert!( + json.contains("\"model\""), + "serialized JSON must always include model; got: {json}" + ); +} + +// ── resolve_effective_model_provider ───────────────────────────────────────── + +fn bare_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "agent".to_string(), + name: "Agent".to_string(), + persona_id: None, + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + relay_mesh: None, + auto_restart_on_config_change: false, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + } +} + +fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "Test Persona".to_string(), + avatar_url: None, + system_prompt: "".to_string(), + runtime: None, + model: model.map(str::to_string), + provider: provider.map(str::to_string), + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + } +} + +/// Tier 1 — agent record wins: record has explicit model/provider; they must +/// outrank both the linked persona and the global defaults. Fails against any +/// implementation that prefers global or persona over the record. +#[test] +fn resolve_agent_record_wins_over_persona_and_global() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + record.model = Some("record-model".to_string()); + record.provider = Some("record-provider".to_string()); + let personas = vec![persona( + "p1", + Some("persona-model"), + Some("persona-provider"), + )]; + let global = GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + ..Default::default() + }; + + let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); + + assert_eq!(model, Some("record-model"), "record model must win"); + assert_eq!( + provider, + Some("record-provider"), + "record provider must win" + ); +} + +/// Tier 2 — persona fallback: record has no model/provider; the linked +/// persona's values must be used. Fails against an implementation that skips +/// persona lookup and returns global or None directly. +#[test] +fn resolve_persona_fallback_when_record_has_none() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + // record.model and record.provider are None + let personas = vec![persona( + "p1", + Some("persona-model"), + Some("persona-provider"), + )]; + let global = GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + ..Default::default() + }; + + let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); + + assert_eq!( + model, + Some("persona-model"), + "persona model must be used when record has none" + ); + assert_eq!( + provider, + Some("persona-provider"), + "persona provider must be used when record has none" + ); +} + +/// Tier 3 — global fallback: record and persona both have no model/provider; +/// global defaults must fill in. This is the core bug Fix 1 addresses — a +/// global-only agent was Ready per readiness but spawned without model/provider. +/// Fails against the pre-fix runtime.rs spawn path that read only record.model. +#[test] +fn resolve_global_fallback_when_record_and_persona_have_none() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + // record.model / provider = None; persona.model / provider = None + let personas = vec![persona("p1", None, None)]; + let global = GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + ..Default::default() + }; + + let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); + + assert_eq!( + model, + Some("global-model"), + "global model must be used when record and persona have none" + ); + assert_eq!( + provider, + Some("global-provider"), + "global provider must be used when record and persona have none" + ); +} + +/// Tier 4 — no persona linked: record.persona_id is None, record has no +/// model/provider; global defaults must still fill in (persona lookup skipped). +#[cfg(feature = "mesh-llm")] +#[test] +fn inherited_shared_compute_translates_to_supported_agent_transport() { + let record = bare_record(); + let personas: Vec = vec![]; + let global = GlobalAgentConfig { + model: Some("auto".to_string()), + provider: Some(super::super::RELAY_MESH_PROVIDER_ID.to_string()), + ..Default::default() + }; + let runtime = super::super::known_acp_runtime("buzz-agent").expect("buzz-agent runtime"); + + let effective = super::super::readiness::resolve_effective_agent_env( + &record, + &personas, + Some(runtime), + &global, + ); + + assert_eq!( + effective.env.get("BUZZ_AGENT_PROVIDER").map(String::as_str), + Some("openai") + ); + assert_eq!( + effective.env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some("auto") + ); + assert_eq!( + effective + .env + .get("OPENAI_COMPAT_BASE_URL") + .map(String::as_str), + Some(super::super::RELAY_MESH_API_BASE_URL) + ); +} + +#[test] +fn resolve_global_fallback_when_no_persona_linked() { + let record = bare_record(); // persona_id = None, model/provider = None + let personas: Vec = vec![]; + let global = GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + ..Default::default() + }; + + let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); + + assert_eq!(model, Some("global-model")); + assert_eq!(provider, Some("global-provider")); +} + +/// All-None: no source provides model/provider → both must be None. +/// Guards against a resolver that synthesizes phantom defaults. +#[test] +fn resolve_all_none_when_no_source_provides_values() { + let record = bare_record(); // persona_id = None, model/provider = None + let personas: Vec = vec![]; + let global = GlobalAgentConfig::default(); // model/provider = None + + let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); + + assert_eq!( + model, None, + "must return None when no source provides a model" + ); + assert_eq!( + provider, None, + "must return None when no source provides a provider" + ); +} + +/// Partial tier — record has model but not provider; persona has provider but +/// not model; global has both. Each field resolves independently through the +/// three-tier chain. +#[test] +fn resolve_each_field_resolves_independently_through_tiers() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + record.model = Some("record-model".to_string()); + // record.provider = None → falls through to persona + let personas = vec![persona("p1", None, Some("persona-provider"))]; + // persona.model = None → global fills model if record also had none, but + // record has model here so global is not needed for model. + let global = GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + ..Default::default() + }; + + let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); + + assert_eq!(model, Some("record-model"), "record wins for model"); + assert_eq!( + provider, + Some("persona-provider"), + "persona wins for provider when record has none" + ); +} + +// ── IPC serialization ───────────────────────────────────────────────────────── + +/// A fully-populated `GlobalAgentConfig` must round-trip through JSON without +/// loss. +#[test] +fn populated_global_config_round_trips() { + let original = GlobalAgentConfig { + env_vars: [("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())] + .into_iter() + .collect(), + provider: Some("anthropic".to_string()), + model: Some("claude-opus-4-5".to_string()), + }; + let json = serde_json::to_string(&original).expect("serialization must not fail"); + let decoded: GlobalAgentConfig = + serde_json::from_str(&json).expect("deserialization must not fail"); + assert_eq!( + decoded, original, + "populated config must round-trip losslessly" + ); +} + +// ── record_agent_command runtime resolution (regression) ───────────────────── + +/// When a record carries `runtime: Some("claude")` and the linked persona has +/// `runtime: Some("goose")`, `record_agent_command` must use the RECORD runtime +/// (`"claude-agent-acp"`) — not the persona runtime (`"goose"`). +/// +/// This is the invariant that `collect_respawn_candidates` / the under-lock +/// re-check in `restart_setup_listener_agent` rely on: the NotReady→Ready +/// evaluation must use the runtime the agent actually spawns with. +#[test] +fn record_runtime_wins_over_persona_runtime_for_command_resolution() { + let mut record = bare_record(); + record.runtime = Some("claude".to_string()); + record.persona_id = Some("p1".to_string()); + + let persona = AgentDefinition { + id: "p1".to_string(), + display_name: "Goose persona".to_string(), + avatar_url: None, + system_prompt: "".to_string(), + runtime: Some("goose".to_string()), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + }; + + let cmd = crate::managed_agents::record_agent_command(&record, &[persona]); + + // record.runtime = "claude" → primary command is "claude-agent-acp" + // (NOT the persona runtime "goose" → "goose") + assert_eq!( + cmd, "claude-agent-acp", + "record runtime must override persona runtime in command resolution" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 68ab8f5a61..afe7f6f405 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,38 +1,62 @@ mod agent_env; pub(crate) mod agent_events; -pub(crate) use agent_env::{build_buzz_agent_provider_defaults, discovery_env_with_baked_floor}; +pub(crate) mod agent_snapshot; +pub(crate) mod team_snapshot; +pub(crate) use agent_env::{ + baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, +}; mod backend; pub(crate) mod config_bridge; mod discovery; mod env_vars; +pub(crate) mod git_bash; +pub(crate) mod global_config; mod nest; mod persona_avatars; -mod persona_card; pub(crate) mod persona_events; mod personas; #[cfg(windows)] mod process_lifecycle; -#[cfg(feature = "mesh-llm")] +pub(crate) mod readiness; +pub(crate) mod reconcile; mod relay_mesh; mod repos; mod restore; pub mod retention; mod runtime; -mod storage; +pub(crate) mod spawn_hash; +pub(crate) mod storage; pub(crate) mod team_events; mod team_repair; mod teams; mod types; +// Shared guard for tests that mutate or read process-global PATH. +#[cfg(test)] +static PATH_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { + PATH_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) +} + pub use backend::*; pub use discovery::*; pub use env_vars::*; +#[cfg(windows)] +pub(crate) use git_bash::git_bash_available; +pub(crate) use git_bash::{discover_git_bash, GitBashPrerequisite}; +pub(crate) use global_config::{ + load_global_agent_config, resolve_effective_model_provider, save_global_agent_config, + validate_global_config, GlobalAgentConfig, +}; pub use nest::*; -pub use persona_card::*; pub use personas::*; #[cfg(windows)] pub use process_lifecycle::*; -#[cfg(feature = "mesh-llm")] +pub(crate) use readiness::{ + agent_readiness, resolve_effective_agent_env, AgentReadiness, Requirement, +}; pub use relay_mesh::*; pub use repos::{ effective_repos_dir, ensure_repos_symlink, resolve_repos_at_boot, validate_repos_dir, @@ -41,7 +65,6 @@ pub use repos::{ pub use restore::*; pub use runtime::*; pub use storage::*; -pub use team_repair::{sync_team_personas, team_persona_key}; pub use teams::*; pub use types::*; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index d6b9bbb148..e13ab2baad 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -7,7 +7,7 @@ //! Static template content in AGENTS.md (above the managed-section markers) //! and SKILL.md is refreshed when the embedded template version changes. -use super::{load_managed_agents, load_personas, ManagedAgentRecord, PersonaRecord}; +use super::{load_managed_agents, load_personas, AgentDefinition, ManagedAgentRecord}; #[cfg(test)] use super::{BackendKind, RespondTo}; use crate::app_state::AppState; @@ -18,6 +18,8 @@ use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager}; use crate::managed_agents::discovery::known_skill_dirs; +#[cfg(unix)] +use crate::util::create_symlink; /// Subdirectories created inside the nest. /// `REPOS` is intentionally absent: it is provisioned by @@ -48,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. -const NEST_SKILL_VERSION: u32 = 3; +const NEST_SKILL_VERSION: u32 = 4; const BEGIN_MARKER: &str = ""; @@ -104,6 +106,31 @@ pub fn nest_dir() -> Option { } } +/// Returns `true` iff `path` ends with the dev-nest directory name (`.buzz-dev`). +/// +/// Pure function — no globals — so it can be unit-tested without touching the +/// process-lifetime [`NEST_DIR`] `OnceLock`. +fn path_is_dev_nest(path: &std::path::Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .map(|n| n == NEST_DIR_DEV) + .unwrap_or(false) +} + +/// Returns `true` when the running binary is using the dev nest (`~/.buzz-dev`). +/// +/// This is `true` for all dev builds — `just staging` and `just dev` — because +/// [`init_nest_dir`] is called with `is_dev = true` when the Tauri app-data +/// directory starts with `"xyz.block.buzz.app.dev"`. +/// +/// Returns `false` when: +/// - The nest is the production nest (`~/.buzz`, signed DMG). +/// - [`init_nest_dir`] has not been called yet (unit tests, home dir +/// unresolvable) — the fallback path is always the prod nest. +pub fn nest_is_dev() -> bool { + nest_dir().map(|p| path_is_dev_nest(&p)).unwrap_or(false) +} + /// Creates the Buzz nest at `~/.buzz` if it doesn't already exist. /// /// Delegates to [`ensure_nest_at`] with the resolved nest directory. @@ -295,7 +322,7 @@ fn ensure_skill_symlinks(root: &Path) -> Result<(), String> { let depth = std::path::Path::new(skill_dir).components().count(); let prefix = "../".repeat(depth); let target = format!("{prefix}{CANONICAL_SKILL_DIR}"); - std::os::unix::fs::symlink(&target, &link) + create_symlink(std::path::Path::new(&target), &link) .map_err(|e| format!("symlink {} → {}: {e}", link.display(), target))?; } Ok(()) @@ -306,16 +333,35 @@ fn ensure_skill_symlinks(_root: &Path) -> Result<(), String> { Ok(()) } -/// Ensures `~/.local/bin/buzz` is a symlink to the bundled CLI binary. +/// Returns the `~/.local/bin` link name for the bundled CLI. /// -/// On every boot: replaces any existing symlink unconditionally (the `buzz` -/// name is our namespace), creates a new one if absent, and leaves regular -/// files alone to avoid clobbering a user-compiled binary. +/// Dev builds (`is_dev = true`) use `"buzz-dev"` so that a running DMG and a +/// concurrent dev build each own a separate link and never clobber each other — +/// the same isolation that separates `~/.buzz` (prod) from `~/.buzz-dev` (dev). +pub fn cli_link_name(is_dev: bool) -> &'static str { + if is_dev { + "buzz-dev" + } else { + "buzz" + } +} + +/// Ensures `~/.local/bin/buzz` (prod) or `~/.local/bin/buzz-dev` (dev) is a +/// symlink to the bundled CLI binary. +/// +/// The link name is split by `is_dev` so that an installed DMG and a +/// concurrently running dev build each maintain their own symlink and never +/// overwrite each other's target — the same isolation that separates the +/// `~/.buzz` and `~/.buzz-dev` nests (see [`NEST_DIR_DEV`]). +/// +/// On every boot: replaces any existing symlink unconditionally (the `buzz` / +/// `buzz-dev` name is our namespace), creates a new one if absent, and leaves +/// regular files alone to avoid clobbering a user-compiled binary. /// /// Non-fatal: callers should ignore errors — the symlink is a convenience /// for human Terminal use; agents find the CLI via PATH augmentation. #[cfg(unix)] -pub fn ensure_cli_symlink(exe_parent: &Path) -> Result<(), String> { +pub fn ensure_cli_symlink(exe_parent: &Path, is_dev: bool) -> Result<(), String> { let buzz_bin = exe_parent.join("buzz"); if !buzz_bin.exists() { return Ok(()); // CLI not bundled (e.g., dev builds without sidecars). @@ -327,18 +373,18 @@ pub fn ensure_cli_symlink(exe_parent: &Path) -> Result<(), String> { .join("bin"); fs::create_dir_all(&local_bin).map_err(|e| format!("create {}: {e}", local_bin.display()))?; - let link = local_bin.join("buzz"); + let link = local_bin.join(cli_link_name(is_dev)); match link.symlink_metadata() { Ok(meta) if meta.file_type().is_symlink() => { let _ = fs::remove_file(&link); - std::os::unix::fs::symlink(&buzz_bin, &link) + create_symlink(&buzz_bin, &link) .map_err(|e| format!("symlink {}: {e}", link.display()))?; } Ok(_) => { // Regular file or directory — don't clobber. } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - std::os::unix::fs::symlink(&buzz_bin, &link) + create_symlink(&buzz_bin, &link) .map_err(|e| format!("symlink {}: {e}", link.display()))?; } Err(e) => { @@ -351,7 +397,7 @@ pub fn ensure_cli_symlink(exe_parent: &Path) -> Result<(), String> { /// No-op on non-Unix platforms — symlink management is macOS/Linux only. #[cfg(not(unix))] -pub fn ensure_cli_symlink(_exe_parent: &Path) -> Result<(), String> { +pub fn ensure_cli_symlink(_exe_parent: &Path, _is_dev: bool) -> Result<(), String> { Ok(()) } @@ -485,8 +531,11 @@ fn refresh_skill_md_if_stale(root: &Path) -> Result<(), String> { fs::remove_file(&symlink_path) .map_err(|e| format!("remove symlink {}: {e}", symlink_path.display()))?; } - std::os::unix::fs::symlink("../../.agents/skills/buzz-cli", &symlink_path) - .map_err(|e| format!("symlink {}: {e}", symlink_path.display()))?; + create_symlink( + std::path::Path::new("../../.agents/skills/buzz-cli"), + &symlink_path, + ) + .map_err(|e| format!("symlink {}: {e}", symlink_path.display()))?; } fs::write(&version_path, format!("{NEST_SKILL_VERSION}\n")) @@ -500,7 +549,7 @@ fn escape_md_cell(s: &str) -> String { } pub fn render_dynamic_section( - personas: &[PersonaRecord], + personas: &[AgentDefinition], agents: &[ManagedAgentRecord], relay_url: &str, ) -> String { @@ -651,850 +700,4 @@ pub fn try_regenerate_nest(app: &AppHandle) { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn nest_dir_is_under_home() { - if let Some(dir) = nest_dir() { - // Accepts both .buzz (prod) and .buzz-dev (dev) depending on - // whether init_nest_dir was called before this test ran. - let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or(""); - assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, - "nest_dir must end with .buzz or .buzz-dev, got {dir:?}" - ); - } - } - - #[test] - fn init_nest_dir_prod_sets_buzz() { - // init_nest_dir is idempotent (OnceLock) — once set, subsequent calls - // are no-ops. We can only test the fallback path if the OnceLock is - // unset, which is only true in a fresh process. Instead, verify that - // nest_dir() always returns a path ending with a valid nest suffix. - let dir = nest_dir(); - if let Some(d) = dir { - let name = d.file_name().and_then(|n| n.to_str()).unwrap_or(""); - assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, - "nest_dir suffix must be .buzz or .buzz-dev, got {d:?}" - ); - } - } - - #[test] - fn ensure_nest_creates_all_dirs_and_agents_md() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - - ensure_nest_at(&root).unwrap(); - - // All subdirectories exist. - for dir in NEST_DIRS { - assert!(root.join(dir).is_dir(), "{dir}/ should exist"); - } - // REPOS is provisioned separately (may be a symlink); with no - // repos_dir configured it lands as a real directory. - assert!(root.join("REPOS").is_dir(), "REPOS/ should exist"); - - // AGENTS.md was written with default content. - let content = fs::read_to_string(root.join("AGENTS.md")).unwrap(); - assert_eq!(content, AGENTS_MD); - - // Permissions are 700 on Unix for root and all subdirs. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(&root).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o700, "root should be 700"); - for dir in NEST_DIRS { - let mode = fs::metadata(root.join(dir)).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o700, "{dir}/ should be 700"); - } - let repos_mode = fs::metadata(root.join("REPOS")) - .unwrap() - .permissions() - .mode() - & 0o777; - assert_eq!(repos_mode, 0o700, "REPOS/ should be 700"); - } - } - - #[test] - fn ensure_nest_is_idempotent_and_preserves_custom_content() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - - // First call creates everything. - ensure_nest_at(&root).unwrap(); - - // User customizes AGENTS.md. - let agents = root.join("AGENTS.md"); - fs::write(&agents, "my custom instructions").unwrap(); - - // Second call succeeds and does not overwrite. - ensure_nest_at(&root).unwrap(); - - assert_eq!( - fs::read_to_string(&agents).unwrap(), - "my custom instructions" - ); - - // All dirs still exist. - for dir in NEST_DIRS { - assert!(root.join(dir).is_dir(), "{dir}/ should still exist"); - } - } - - #[cfg(unix)] - #[test] - fn ensure_nest_rejects_symlink_root() { - let tmp = tempfile::tempdir().unwrap(); - let target = tmp.path().join("real_dir"); - fs::create_dir(&target).unwrap(); - let link = tmp.path().join(".buzz"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - - let result = ensure_nest_at(&link); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("symlink")); - } - - #[test] - fn ensure_nest_creates_skill_file() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - ensure_nest_at(&root).unwrap(); - - // Canonical location under .agents. - let skill = root.join(".agents/skills/buzz-cli/SKILL.md"); - assert!(skill.exists(), "SKILL.md should exist at .agents path"); - let content = fs::read_to_string(&skill).unwrap(); - assert_eq!(content, BUZZ_CLI_SKILL_MD); - - // On unix, harness-specific symlinks should resolve to the canonical dir. - #[cfg(unix)] - { - for dir in [".goose/skills", ".claude/skills", ".codex/skills"] { - let link = root.join(dir).join("buzz-cli"); - assert!( - link.symlink_metadata().unwrap().file_type().is_symlink(), - "{dir}/buzz-cli should be a symlink" - ); - assert!( - link.join("SKILL.md").exists(), - "symlink at {dir}/buzz-cli should resolve to dir with SKILL.md" - ); - } - } - } - - #[test] - fn ensure_nest_does_not_overwrite_skill_file() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - ensure_nest_at(&root).unwrap(); - - let skill = root.join(".agents/skills/buzz-cli/SKILL.md"); - fs::write(&skill, "custom skill content").unwrap(); - - ensure_nest_at(&root).unwrap(); - assert_eq!(fs::read_to_string(&skill).unwrap(), "custom skill content"); - } - - #[cfg(unix)] - #[test] - fn ensure_nest_skill_dir_has_700_permissions() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - ensure_nest_at(&root).unwrap(); - // Canonical path and all provider parent dirs should be locked down. - // Symlinks (e.g. .goose/skills/buzz-cli) are skipped by the chmod loop. - for dir in [ - ".agents", - ".agents/skills", - ".agents/skills/buzz-cli", - ".goose", - ".goose/skills", - ".claude", - ".claude/skills", - ".codex", - ".codex/skills", - ] { - let path = root.join(dir); - let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o700, "{dir} should be 700"); - } - } - - #[cfg(unix)] - #[test] - fn ensure_nest_skips_permissions_on_symlinked_child() { - use std::os::unix::fs::PermissionsExt; - - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - - // First call creates the real nest. - ensure_nest_at(&root).unwrap(); - - // Replace REPOS/ with a symlink to an external directory. - let external = tmp.path().join("external"); - fs::create_dir(&external).unwrap(); - fs::set_permissions(&external, fs::Permissions::from_mode(0o755)).unwrap(); - fs::remove_dir(root.join("REPOS")).unwrap(); - std::os::unix::fs::symlink(&external, root.join("REPOS")).unwrap(); - - // Second call should succeed — it skips chmod on the symlinked child. - ensure_nest_at(&root).unwrap(); - - // The external directory's permissions should be unchanged (755, not 700). - let mode = fs::metadata(&external).unwrap().permissions().mode() & 0o777; - assert_eq!( - mode, 0o755, - "symlinked child's target should not be chmod'd" - ); - } - - #[cfg(unix)] - #[test] - fn ensure_nest_migrates_old_skill_dir() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - - // Simulate a pre-migration install: real directory at old path. - // Create the nest first to get all dirs, then simulate old layout. - ensure_nest_at(&root).unwrap(); - - // Remove the symlink and new skill dir, recreate old real dir. - let _ = fs::remove_file(root.join(".claude/skills/buzz-cli")); - let _ = fs::remove_dir_all(root.join(".agents/skills/buzz-cli")); - let old_skill_dir = root.join(".claude/skills/buzz-cli"); - fs::create_dir_all(&old_skill_dir).unwrap(); - fs::write(old_skill_dir.join("SKILL.md"), "user edited skill").unwrap(); - - // Delete version file to force refresh. - let _ = fs::remove_file(root.join(".agents/skills/buzz-cli/.skill-version")); - - // Re-run ensure_nest_at — should trigger migration in refresh_skill_md_if_stale. - ensure_nest_at(&root).unwrap(); - - // New canonical location exists with user's content preserved. - let new_skill = root.join(".agents/skills/buzz-cli/SKILL.md"); - assert!(new_skill.exists(), "SKILL.md should exist at new path"); - assert_eq!(fs::read_to_string(&new_skill).unwrap(), "user edited skill"); - - // Old path is now a symlink, not a real directory. - let old_path = root.join(".claude/skills/buzz-cli"); - assert!( - old_path - .symlink_metadata() - .unwrap() - .file_type() - .is_symlink(), - "old path should now be a symlink" - ); - } - - #[cfg(unix)] - #[test] - fn ensure_skill_symlinks_are_idempotent() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - ensure_nest_at(&root).unwrap(); - // Second call should succeed without errors. - ensure_nest_at(&root).unwrap(); - // All symlinks still valid and point to relative targets. - for dir in [".goose/skills", ".claude/skills", ".codex/skills"] { - let link = root.join(dir).join("buzz-cli"); - assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); - assert!( - link.join("SKILL.md").exists(), - "symlink at {dir}/buzz-cli should resolve to dir with SKILL.md" - ); - let target = fs::read_link(&link).unwrap(); - assert_eq!( - target.to_str().unwrap(), - format!("../../{CANONICAL_SKILL_DIR}"), - "symlink at {dir}/buzz-cli should use relative target" - ); - } - } - - #[cfg(unix)] - #[test] - fn ensure_skill_symlinks_skips_existing_path_during_initial_pass() { - // ensure_skill_symlinks skips any path where symlink_metadata succeeds. - // However, refresh_skill_md_if_stale (called after ensure_skill_symlinks) - // migrates pre-existing real directories at .claude/skills/buzz-cli to - // symlinks. This test verifies the end-to-end behavior: a pre-existing real - // dir at the claude path is migrated to a symlink. - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - // Pre-create a real directory where a symlink would go. - let real_dir = root.join(".claude/skills/buzz-cli"); - fs::create_dir_all(&real_dir).unwrap(); - // Place SKILL.md so migration preserves it. - fs::write(real_dir.join("SKILL.md"), "custom skill content").unwrap(); - - ensure_nest_at(&root).unwrap(); - - // Migration converts the real dir to a symlink; content is moved to canonical path. - assert!( - real_dir - .symlink_metadata() - .unwrap() - .file_type() - .is_symlink(), - ".claude/skills/buzz-cli should be migrated to a symlink" - ); - // The canonical path now holds the migrated content. - let canonical = root.join(".agents/skills/buzz-cli/SKILL.md"); - assert_eq!( - fs::read_to_string(&canonical).unwrap(), - "custom skill content" - ); - } - - #[cfg(unix)] - #[test] - fn ensure_skill_symlinks_skip_dangling_symlink() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - // Pre-create a dangling symlink where the .codex link would go. - let codex_skills = root.join(".codex/skills"); - fs::create_dir_all(&codex_skills).unwrap(); - let dangling = codex_skills.join("buzz-cli"); - std::os::unix::fs::symlink("/nonexistent/target", &dangling).unwrap(); - - ensure_nest_at(&root).unwrap(); - - // Dangling symlink should be left alone (not clobbered). - assert!(dangling - .symlink_metadata() - .unwrap() - .file_type() - .is_symlink()); - assert_eq!( - fs::read_link(&dangling).unwrap().to_str().unwrap(), - "/nonexistent/target" - ); - } - - #[cfg(unix)] - #[test] - fn ensure_cli_symlink_creates_symlink() { - let tmp = tempfile::tempdir().unwrap(); - let exe_parent = tmp.path().join("MacOS"); - fs::create_dir(&exe_parent).unwrap(); - fs::write(exe_parent.join("buzz"), "binary").unwrap(); - - // Simulate the symlink creation path. - let local_bin = tmp.path().join("local_bin"); - fs::create_dir_all(&local_bin).unwrap(); - let link = local_bin.join("buzz"); - - std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap(); - assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); - assert_eq!(fs::read_link(&link).unwrap(), exe_parent.join("buzz")); - } - - #[cfg(unix)] - #[test] - fn ensure_cli_symlink_does_not_clobber_regular_file() { - let tmp = tempfile::tempdir().unwrap(); - let local_bin = tmp.path().join("local_bin"); - fs::create_dir_all(&local_bin).unwrap(); - let link = local_bin.join("buzz"); - fs::write(&link, "user-installed binary").unwrap(); - - // Regular files are preserved — the Ok(_) branch skips them. - assert!(link.symlink_metadata().unwrap().file_type().is_file()); - assert_eq!(fs::read_to_string(&link).unwrap(), "user-installed binary"); - } - - fn make_persona(id: &str, display_name: &str) -> PersonaRecord { - PersonaRecord { - id: id.to_string(), - display_name: display_name.to_string(), - avatar_url: None, - system_prompt: String::new(), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - env_vars: std::collections::BTreeMap::new(), - created_at: String::new(), - updated_at: String::new(), - } - } - - fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: String::new(), - name: name.to_string(), - persona_id: persona_id.map(|s| s.to_string()), - private_key_nsec: String::new(), - auth_tag: None, - relay_url: String::new(), - avatar_url: None, - acp_command: String::new(), - agent_command: String::new(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 0, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - mcp_toolsets: None, - start_on_app_launch: false, - runtime_pid: None, - backend: BackendKind::default(), - backend_agent_id: None, - provider_binary_path: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: String::new(), - updated_at: String::new(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - respond_to: RespondTo::default(), - respond_to_allowlist: vec![], - env_vars: std::collections::BTreeMap::new(), - relay_mesh: None, - } - } - - #[test] - fn test_render_dynamic_section_with_agents() { - let personas = vec![make_persona("p1", "Builder")]; - let agents = vec![make_agent("Kit", Some("p1"))]; - let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); - assert!(output.contains("| Kit | Builder | @Kit |")); - assert!(output.contains("| Name | Persona | How to address |")); - assert!(output.contains("## Workspace")); - } - - #[test] - fn test_render_dynamic_section_empty() { - let output = render_dynamic_section(&[], &[], "ws://example.com:3000"); - assert!(output.contains("No agents deployed yet")); - } - - #[test] - fn test_render_dynamic_section_agent_no_persona() { - let personas = vec![make_persona("p1", "Builder")]; - let agents = vec![make_agent("Scout", Some("nonexistent"))]; - let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); - assert!(output.contains("| Scout | — | @Scout |")); - } - - #[test] - fn test_upsert_managed_section_with_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\nsome content\n\n\nold section\n\n\nafter\n", - ) - .unwrap(); - - upsert_managed_section(&file, "new section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - assert!(result.contains("")); - assert!(result.contains("new section")); - assert!(!result.contains("old section")); - assert!(result.contains("# Header")); - assert!(result.contains("some content")); - assert!(result.contains("after")); - } - - #[test] - fn test_upsert_managed_section_without_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write(&file, "# Header\n\nexisting content\n").unwrap(); - - upsert_managed_section(&file, "injected section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - assert!(result.contains("# Header")); - assert!(result.contains("existing content")); - assert!(result.contains("")); - assert!(result.contains("injected section")); - let begin_pos = result.find("\nsome middle content\n\nold section\n", - ) - .unwrap(); - - upsert_managed_section(&file, "new section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!(result.contains("# Header"), "original header must survive"); - assert!( - result.contains("new section"), - "new content must be present" - ); - assert!( - result.contains("some middle content"), - "content between markers must survive" - ); - - // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). - assert_eq!( - result.matches(BEGIN_MARKER).count(), - 1, - "exactly one BEGIN marker after orphan cleanup" - ); - - // The single BEGIN marker must have a matching END marker after it. - let begin_pos = result - .find(BEGIN_MARKER) - .expect("BEGIN marker must be present"); - let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); - assert!( - end_pos.is_some(), - "an END marker must appear after the appended BEGIN marker" - ); - } - - #[test] - fn test_upsert_begin_only_no_end() { - // A file with BEGIN but no END has an orphan marker. - // find_managed_markers returns None (no END found after BEGIN), - // so strip_orphan_begin_marker removes the BEGIN line. - // Content that followed the orphan BEGIN is preserved (only the marker line is stripped, - // not the body that came after it). - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\nsome content\n\n\norphaned section without end marker\n", - ) - .unwrap(); - - upsert_managed_section(&file, "fresh section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!(result.contains("# Header"), "original header must survive"); - assert!( - result.contains("some content"), - "original body must survive" - ); - assert!( - result.contains("fresh section"), - "new content must be present" - ); - - let begin_pos = result - .find(BEGIN_MARKER) - .expect("BEGIN marker must be present"); - let end_pos = result.find(END_MARKER).expect("END marker must be present"); - assert!( - begin_pos < end_pos, - "the appended BEGIN marker must precede the appended END marker" - ); - - // Exactly one BEGIN marker after orphan cleanup. - assert_eq!( - result.matches(BEGIN_MARKER).count(), - 1, - "exactly one BEGIN marker after orphan cleanup" - ); - } - - #[test] - fn test_upsert_duplicate_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n", - ) - .unwrap(); - - upsert_managed_section(&file, "replaced").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!( - result.contains("replaced"), - "replacement content must be present" - ); - assert!( - !result.contains("first block"), - "first block must be replaced" - ); - assert!( - result.contains("second block"), - "second pair content must survive" - ); - assert!( - result.contains("between blocks"), - "text between pairs must survive" - ); - } - - #[test] - fn test_upsert_marker_in_code_block() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - // Indented by 4 spaces — not at column 0, so should NOT match as a real marker. - fs::write( - &file, - "# Header\n\n \n\nReal content here\n", - ) - .unwrap(); - - upsert_managed_section(&file, "appended content").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!( - result.contains(" "), - "indented marker inside code block must be preserved verbatim" - ); - assert!( - result.contains("appended content"), - "new content must be appended" - ); - assert!( - result.contains("Real content here"), - "existing body must survive" - ); - - // The real markers appended at the end must be at line-start (column 0). - let begin_pos = result - .find("\nexisting section\n\n", - ) - .unwrap(); - - upsert_managed_section(&file, "same content").unwrap(); - let after_first = fs::read_to_string(&file).unwrap(); - - upsert_managed_section(&file, "same content").unwrap(); - let after_second = fs::read_to_string(&file).unwrap(); - - assert_eq!( - after_first, after_second, - "upsert must be idempotent: second call must not alter the file" - ); - } - - #[test] - fn refresh_agents_md_writes_version_file() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - ensure_nest_at(&root).unwrap(); - let version = fs::read_to_string(root.join(".nest-agents-version")).unwrap(); - assert_eq!(version.trim(), NEST_AGENTS_VERSION.to_string()); - } - - #[test] - fn refresh_skill_md_writes_version_file() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - ensure_nest_at(&root).unwrap(); - let version = - fs::read_to_string(root.join(".agents/skills/buzz-cli/.skill-version")).unwrap(); - assert_eq!(version.trim(), NEST_SKILL_VERSION.to_string()); - } - - #[test] - fn refresh_agents_md_preserves_managed_section() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - ensure_nest_at(&root).unwrap(); - - // Simulate a managed section update. - let agents_md = root.join("AGENTS.md"); - upsert_managed_section( - &agents_md, - "## Active Agents\n\n| Name | Role |\n|------|------|\n| Kit | Builder |", - ) - .unwrap(); - - // Remove version file to simulate an upgrade. - fs::remove_file(root.join(".nest-agents-version")).unwrap(); - - // Re-run ensure_nest_at (triggers refresh). - ensure_nest_at(&root).unwrap(); - - let content = fs::read_to_string(&agents_md).unwrap(); - // Static content should be refreshed (from template). - assert!( - content.starts_with("# Buzz Nest"), - "template header must be present" - ); - // Managed section should be preserved. - assert!( - content.contains("Kit"), - "managed section agent table must survive refresh" - ); - assert!(content.contains(BEGIN_MARKER), "BEGIN marker must survive"); - assert!(content.contains(END_MARKER), "END marker must survive"); - } - - #[test] - fn refresh_skips_when_version_current() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - ensure_nest_at(&root).unwrap(); - - // Manually change AGENTS.md content after version file is written. - let agents_md = root.join("AGENTS.md"); - fs::write(&agents_md, "user modified content").unwrap(); - - // Re-run ensure_nest_at — version file is current, so no refresh. - ensure_nest_at(&root).unwrap(); - - let content = fs::read_to_string(&agents_md).unwrap(); - assert_eq!( - content, "user modified content", - "should not overwrite when version is current" - ); - } - - #[test] - fn refresh_skill_overwrites_on_version_bump() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - ensure_nest_at(&root).unwrap(); - - let skill_md = root.join(".agents/skills/buzz-cli/SKILL.md"); - fs::write(&skill_md, "stale skill content").unwrap(); - - // Remove version file to simulate upgrade. - let _ = fs::remove_file(root.join(".agents/skills/buzz-cli/.skill-version")); - - ensure_nest_at(&root).unwrap(); - - let content = fs::read_to_string(&skill_md).unwrap(); - assert_eq!( - content, BUZZ_CLI_SKILL_MD, - "SKILL.md must be refreshed on version bump" - ); - } -} +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs new file mode 100644 index 0000000000..a959381603 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -0,0 +1,945 @@ +use super::*; + +#[test] +fn nest_dir_is_under_home() { + if let Some(dir) = nest_dir() { + // Accepts both .buzz (prod) and .buzz-dev (dev) depending on + // whether init_nest_dir was called before this test ran. + let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or(""); + assert!( + name == NEST_DIR_PROD || name == NEST_DIR_DEV, + "nest_dir must end with .buzz or .buzz-dev, got {dir:?}" + ); + } +} + +#[test] +fn init_nest_dir_prod_sets_buzz() { + // init_nest_dir is idempotent (OnceLock) — once set, subsequent calls + // are no-ops. We can only test the fallback path if the OnceLock is + // unset, which is only true in a fresh process. Instead, verify that + // nest_dir() always returns a path ending with a valid nest suffix. + let dir = nest_dir(); + if let Some(d) = dir { + let name = d.file_name().and_then(|n| n.to_str()).unwrap_or(""); + assert!( + name == NEST_DIR_PROD || name == NEST_DIR_DEV, + "nest_dir suffix must be .buzz or .buzz-dev, got {d:?}" + ); + } +} + +#[test] +fn ensure_nest_creates_all_dirs_and_agents_md() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + + ensure_nest_at(&root).unwrap(); + + // All subdirectories exist. + for dir in NEST_DIRS { + assert!(root.join(dir).is_dir(), "{dir}/ should exist"); + } + // REPOS is provisioned separately (may be a symlink); with no + // repos_dir configured it lands as a real directory. + assert!(root.join("REPOS").is_dir(), "REPOS/ should exist"); + + // AGENTS.md was written with default content. + let content = fs::read_to_string(root.join("AGENTS.md")).unwrap(); + assert_eq!(content, AGENTS_MD); + + // Permissions are 700 on Unix for root and all subdirs. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&root).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "root should be 700"); + for dir in NEST_DIRS { + let mode = fs::metadata(root.join(dir)).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "{dir}/ should be 700"); + } + let repos_mode = fs::metadata(root.join("REPOS")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(repos_mode, 0o700, "REPOS/ should be 700"); + } +} + +#[test] +fn ensure_nest_is_idempotent_and_preserves_custom_content() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + + // First call creates everything. + ensure_nest_at(&root).unwrap(); + + // User customizes AGENTS.md. + let agents = root.join("AGENTS.md"); + fs::write(&agents, "my custom instructions").unwrap(); + + // Second call succeeds and does not overwrite. + ensure_nest_at(&root).unwrap(); + + assert_eq!( + fs::read_to_string(&agents).unwrap(), + "my custom instructions" + ); + + // All dirs still exist. + for dir in NEST_DIRS { + assert!(root.join(dir).is_dir(), "{dir}/ should still exist"); + } +} + +#[cfg(unix)] +#[test] +fn ensure_nest_rejects_symlink_root() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("real_dir"); + fs::create_dir(&target).unwrap(); + let link = tmp.path().join(".buzz"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let result = ensure_nest_at(&link); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("symlink")); +} + +#[test] +fn ensure_nest_creates_skill_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + + // Canonical location under .agents. + let skill = root.join(".agents/skills/buzz-cli/SKILL.md"); + assert!(skill.exists(), "SKILL.md should exist at .agents path"); + let content = fs::read_to_string(&skill).unwrap(); + assert_eq!(content, BUZZ_CLI_SKILL_MD); + + // On unix, harness-specific symlinks should resolve to the canonical dir. + #[cfg(unix)] + { + for dir in [".goose/skills", ".claude/skills", ".codex/skills"] { + let link = root.join(dir).join("buzz-cli"); + assert!( + link.symlink_metadata().unwrap().file_type().is_symlink(), + "{dir}/buzz-cli should be a symlink" + ); + assert!( + link.join("SKILL.md").exists(), + "symlink at {dir}/buzz-cli should resolve to dir with SKILL.md" + ); + } + } +} + +#[test] +fn ensure_nest_does_not_overwrite_skill_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + + let skill = root.join(".agents/skills/buzz-cli/SKILL.md"); + fs::write(&skill, "custom skill content").unwrap(); + + ensure_nest_at(&root).unwrap(); + assert_eq!(fs::read_to_string(&skill).unwrap(), "custom skill content"); +} + +#[cfg(unix)] +#[test] +fn ensure_nest_skill_dir_has_700_permissions() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + // Canonical path and all provider parent dirs should be locked down. + // Symlinks (e.g. .goose/skills/buzz-cli) are skipped by the chmod loop. + for dir in [ + ".agents", + ".agents/skills", + ".agents/skills/buzz-cli", + ".goose", + ".goose/skills", + ".claude", + ".claude/skills", + ".codex", + ".codex/skills", + ] { + let path = root.join(dir); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "{dir} should be 700"); + } +} + +#[cfg(unix)] +#[test] +fn ensure_nest_skips_permissions_on_symlinked_child() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + + // First call creates the real nest. + ensure_nest_at(&root).unwrap(); + + // Replace REPOS/ with a symlink to an external directory. + let external = tmp.path().join("external"); + fs::create_dir(&external).unwrap(); + fs::set_permissions(&external, fs::Permissions::from_mode(0o755)).unwrap(); + fs::remove_dir(root.join("REPOS")).unwrap(); + std::os::unix::fs::symlink(&external, root.join("REPOS")).unwrap(); + + // Second call should succeed — it skips chmod on the symlinked child. + ensure_nest_at(&root).unwrap(); + + // The external directory's permissions should be unchanged (755, not 700). + let mode = fs::metadata(&external).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o755, + "symlinked child's target should not be chmod'd" + ); +} + +#[cfg(unix)] +#[test] +fn ensure_nest_migrates_old_skill_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + + // Simulate a pre-migration install: real directory at old path. + // Create the nest first to get all dirs, then simulate old layout. + ensure_nest_at(&root).unwrap(); + + // Remove the symlink and new skill dir, recreate old real dir. + let _ = fs::remove_file(root.join(".claude/skills/buzz-cli")); + let _ = fs::remove_dir_all(root.join(".agents/skills/buzz-cli")); + let old_skill_dir = root.join(".claude/skills/buzz-cli"); + fs::create_dir_all(&old_skill_dir).unwrap(); + fs::write(old_skill_dir.join("SKILL.md"), "user edited skill").unwrap(); + + // Delete version file to force refresh. + let _ = fs::remove_file(root.join(".agents/skills/buzz-cli/.skill-version")); + + // Re-run ensure_nest_at — should trigger migration in refresh_skill_md_if_stale. + ensure_nest_at(&root).unwrap(); + + // New canonical location exists with user's content preserved. + let new_skill = root.join(".agents/skills/buzz-cli/SKILL.md"); + assert!(new_skill.exists(), "SKILL.md should exist at new path"); + assert_eq!(fs::read_to_string(&new_skill).unwrap(), "user edited skill"); + + // Old path is now a symlink, not a real directory. + let old_path = root.join(".claude/skills/buzz-cli"); + assert!( + old_path + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "old path should now be a symlink" + ); +} + +#[cfg(unix)] +#[test] +fn ensure_skill_symlinks_are_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + // Second call should succeed without errors. + ensure_nest_at(&root).unwrap(); + // All symlinks still valid and point to relative targets. + for dir in [".goose/skills", ".claude/skills", ".codex/skills"] { + let link = root.join(dir).join("buzz-cli"); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + assert!( + link.join("SKILL.md").exists(), + "symlink at {dir}/buzz-cli should resolve to dir with SKILL.md" + ); + let target = fs::read_link(&link).unwrap(); + assert_eq!( + target.to_str().unwrap(), + format!("../../{CANONICAL_SKILL_DIR}"), + "symlink at {dir}/buzz-cli should use relative target" + ); + } +} + +#[cfg(unix)] +#[test] +fn ensure_skill_symlinks_skips_existing_path_during_initial_pass() { + // ensure_skill_symlinks skips any path where symlink_metadata succeeds. + // However, refresh_skill_md_if_stale (called after ensure_skill_symlinks) + // migrates pre-existing real directories at .claude/skills/buzz-cli to + // symlinks. This test verifies the end-to-end behavior: a pre-existing real + // dir at the claude path is migrated to a symlink. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + // Pre-create a real directory where a symlink would go. + let real_dir = root.join(".claude/skills/buzz-cli"); + fs::create_dir_all(&real_dir).unwrap(); + // Place SKILL.md so migration preserves it. + fs::write(real_dir.join("SKILL.md"), "custom skill content").unwrap(); + + ensure_nest_at(&root).unwrap(); + + // Migration converts the real dir to a symlink; content is moved to canonical path. + assert!( + real_dir + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + ".claude/skills/buzz-cli should be migrated to a symlink" + ); + // The canonical path now holds the migrated content. + let canonical = root.join(".agents/skills/buzz-cli/SKILL.md"); + assert_eq!( + fs::read_to_string(&canonical).unwrap(), + "custom skill content" + ); +} + +#[cfg(unix)] +#[test] +fn ensure_skill_symlinks_skip_dangling_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + // Pre-create a dangling symlink where the .codex link would go. + let codex_skills = root.join(".codex/skills"); + fs::create_dir_all(&codex_skills).unwrap(); + let dangling = codex_skills.join("buzz-cli"); + std::os::unix::fs::symlink("/nonexistent/target", &dangling).unwrap(); + + ensure_nest_at(&root).unwrap(); + + // Dangling symlink should be left alone (not clobbered). + assert!(dangling + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink()); + assert_eq!( + fs::read_link(&dangling).unwrap().to_str().unwrap(), + "/nonexistent/target" + ); +} + +#[test] +fn cli_link_name_prod_is_buzz() { + assert_eq!(cli_link_name(false), "buzz"); +} + +#[test] +fn cli_link_name_dev_is_buzz_dev() { + assert_eq!(cli_link_name(true), "buzz-dev"); +} + +#[cfg(unix)] +#[test] +fn ensure_cli_symlink_creates_symlink_prod() { + let tmp = tempfile::tempdir().unwrap(); + let exe_parent = tmp.path().join("MacOS"); + fs::create_dir(&exe_parent).unwrap(); + fs::write(exe_parent.join("buzz"), "binary").unwrap(); + + let local_bin = tmp.path().join("local_bin"); + fs::create_dir_all(&local_bin).unwrap(); + + // Prod link name is "buzz"; simulate the symlink creation path. + let link = local_bin.join(cli_link_name(false)); + std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap(); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(fs::read_link(&link).unwrap(), exe_parent.join("buzz")); +} + +#[cfg(unix)] +#[test] +fn ensure_cli_symlink_creates_symlink_dev() { + let tmp = tempfile::tempdir().unwrap(); + let exe_parent = tmp.path().join("MacOS"); + fs::create_dir(&exe_parent).unwrap(); + fs::write(exe_parent.join("buzz"), "binary").unwrap(); + + let local_bin = tmp.path().join("local_bin"); + fs::create_dir_all(&local_bin).unwrap(); + + // Dev link must be "buzz-dev", never "buzz". + assert_eq!(cli_link_name(true), "buzz-dev"); + + let link = local_bin.join(cli_link_name(true)); + std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap(); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(fs::read_link(&link).unwrap(), exe_parent.join("buzz")); + // Prod link must not exist — the two builds don't touch each other. + assert!(!local_bin.join("buzz").exists()); +} + +#[cfg(unix)] +#[test] +fn ensure_cli_symlink_does_not_clobber_regular_file_prod() { + let tmp = tempfile::tempdir().unwrap(); + let local_bin = tmp.path().join("local_bin"); + fs::create_dir_all(&local_bin).unwrap(); + let link = local_bin.join(cli_link_name(false)); + fs::write(&link, "user-installed binary").unwrap(); + + // Regular files are preserved — the Ok(_) branch skips them. + assert!(link.symlink_metadata().unwrap().file_type().is_file()); + assert_eq!(fs::read_to_string(&link).unwrap(), "user-installed binary"); +} + +#[cfg(unix)] +#[test] +fn ensure_cli_symlink_does_not_clobber_regular_file_dev() { + let tmp = tempfile::tempdir().unwrap(); + let local_bin = tmp.path().join("local_bin"); + fs::create_dir_all(&local_bin).unwrap(); + let link = local_bin.join(cli_link_name(true)); + fs::write(&link, "user-installed buzz-dev binary").unwrap(); + + // Regular files at the dev path are also preserved. + assert!(link.symlink_metadata().unwrap().file_type().is_file()); + assert_eq!( + fs::read_to_string(&link).unwrap(), + "user-installed buzz-dev binary" + ); +} + +fn make_persona(id: &str, display_name: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: display_name.to_string(), + avatar_url: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } +} + +fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: name.to_string(), + persona_id: persona_id.map(|s| s.to_string()), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + env_vars: std::collections::BTreeMap::new(), + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} + +#[test] +fn test_render_dynamic_section_with_agents() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Kit", Some("p1"))]; + let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); + assert!(output.contains("| Kit | Builder | @Kit |")); + assert!(output.contains("| Name | Persona | How to address |")); + assert!(output.contains("## Workspace")); +} + +#[test] +fn test_render_dynamic_section_empty() { + let output = render_dynamic_section(&[], &[], "ws://example.com:3000"); + assert!(output.contains("No agents deployed yet")); +} + +#[test] +fn test_render_dynamic_section_agent_no_persona() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Scout", Some("nonexistent"))]; + let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); + assert!(output.contains("| Scout | — | @Scout |")); +} + +#[test] +fn test_upsert_managed_section_with_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\nold section\n\n\nafter\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("")); + assert!(result.contains("new section")); + assert!(!result.contains("old section")); + assert!(result.contains("# Header")); + assert!(result.contains("some content")); + assert!(result.contains("after")); +} + +#[test] +fn test_upsert_managed_section_without_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write(&file, "# Header\n\nexisting content\n").unwrap(); + + upsert_managed_section(&file, "injected section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("# Header")); + assert!(result.contains("existing content")); + assert!(result.contains("")); + assert!(result.contains("injected section")); + let begin_pos = result.find("\nsome middle content\n\nold section\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!( + result.contains("new section"), + "new content must be present" + ); + assert!( + result.contains("some middle content"), + "content between markers must survive" + ); + + // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); + + // The single BEGIN marker must have a matching END marker after it. + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); + let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); + assert!( + end_pos.is_some(), + "an END marker must appear after the appended BEGIN marker" + ); +} + +#[test] +fn test_upsert_begin_only_no_end() { + // A file with BEGIN but no END has an orphan marker. + // find_managed_markers returns None (no END found after BEGIN), + // so strip_orphan_begin_marker removes the BEGIN line. + // Content that followed the orphan BEGIN is preserved (only the marker line is stripped, + // not the body that came after it). + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\norphaned section without end marker\n", + ) + .unwrap(); + + upsert_managed_section(&file, "fresh section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!( + result.contains("some content"), + "original body must survive" + ); + assert!( + result.contains("fresh section"), + "new content must be present" + ); + + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); + let end_pos = result.find(END_MARKER).expect("END marker must be present"); + assert!( + begin_pos < end_pos, + "the appended BEGIN marker must precede the appended END marker" + ); + + // Exactly one BEGIN marker after orphan cleanup. + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); +} + +#[test] +fn test_upsert_duplicate_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "replaced").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!( + result.contains("replaced"), + "replacement content must be present" + ); + assert!( + !result.contains("first block"), + "first block must be replaced" + ); + assert!( + result.contains("second block"), + "second pair content must survive" + ); + assert!( + result.contains("between blocks"), + "text between pairs must survive" + ); +} + +#[test] +fn test_upsert_marker_in_code_block() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + // Indented by 4 spaces — not at column 0, so should NOT match as a real marker. + fs::write( + &file, + "# Header\n\n \n\nReal content here\n", + ) + .unwrap(); + + upsert_managed_section(&file, "appended content").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!( + result.contains(" "), + "indented marker inside code block must be preserved verbatim" + ); + assert!( + result.contains("appended content"), + "new content must be appended" + ); + assert!( + result.contains("Real content here"), + "existing body must survive" + ); + + // The real markers appended at the end must be at line-start (column 0). + let begin_pos = result + .find("\nexisting section\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_first = fs::read_to_string(&file).unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_second = fs::read_to_string(&file).unwrap(); + + assert_eq!( + after_first, after_second, + "upsert must be idempotent: second call must not alter the file" + ); +} + +#[test] +fn refresh_agents_md_writes_version_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + let version = fs::read_to_string(root.join(".nest-agents-version")).unwrap(); + assert_eq!(version.trim(), NEST_AGENTS_VERSION.to_string()); +} + +#[test] +fn refresh_skill_md_writes_version_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + let version = fs::read_to_string(root.join(".agents/skills/buzz-cli/.skill-version")).unwrap(); + assert_eq!(version.trim(), NEST_SKILL_VERSION.to_string()); +} + +#[test] +fn refresh_agents_md_preserves_managed_section() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + + // Simulate a managed section update. + let agents_md = root.join("AGENTS.md"); + upsert_managed_section( + &agents_md, + "## Active Agents\n\n| Name | Role |\n|------|------|\n| Kit | Builder |", + ) + .unwrap(); + + // Remove version file to simulate an upgrade. + fs::remove_file(root.join(".nest-agents-version")).unwrap(); + + // Re-run ensure_nest_at (triggers refresh). + ensure_nest_at(&root).unwrap(); + + let content = fs::read_to_string(&agents_md).unwrap(); + // Static content should be refreshed (from template). + assert!( + content.starts_with("# Buzz Nest"), + "template header must be present" + ); + // Managed section should be preserved. + assert!( + content.contains("Kit"), + "managed section agent table must survive refresh" + ); + assert!(content.contains(BEGIN_MARKER), "BEGIN marker must survive"); + assert!(content.contains(END_MARKER), "END marker must survive"); +} + +#[test] +fn refresh_skips_when_version_current() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + + // Manually change AGENTS.md content after version file is written. + let agents_md = root.join("AGENTS.md"); + fs::write(&agents_md, "user modified content").unwrap(); + + // Re-run ensure_nest_at — version file is current, so no refresh. + ensure_nest_at(&root).unwrap(); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert_eq!( + content, "user modified content", + "should not overwrite when version is current" + ); +} + +#[test] +fn refresh_skill_overwrites_on_version_bump() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + + let skill_md = root.join(".agents/skills/buzz-cli/SKILL.md"); + fs::write(&skill_md, "stale skill content").unwrap(); + + // Remove version file to simulate upgrade. + let _ = fs::remove_file(root.join(".agents/skills/buzz-cli/.skill-version")); + + ensure_nest_at(&root).unwrap(); + + let content = fs::read_to_string(&skill_md).unwrap(); + assert_eq!( + content, BUZZ_CLI_SKILL_MD, + "SKILL.md must be refreshed on version bump" + ); +} + +#[test] +fn test_path_is_dev_nest_dev_path_returns_true() { + let path = std::path::Path::new("/Users/someone/.buzz-dev"); + assert!( + path_is_dev_nest(path), + ".buzz-dev path must be identified as dev nest" + ); +} + +#[test] +fn test_path_is_dev_nest_prod_path_returns_false() { + let path = std::path::Path::new("/Users/someone/.buzz"); + assert!( + !path_is_dev_nest(path), + ".buzz path must not be identified as dev nest" + ); +} + +#[test] +fn test_path_is_dev_nest_unrelated_path_returns_false() { + let path = std::path::Path::new("/Users/someone/.buzz-staging"); + assert!( + !path_is_dev_nest(path), + "unrelated path must not be identified as dev nest" + ); +} + +#[test] +fn test_path_is_dev_nest_root_returns_false() { + let path = std::path::Path::new("/"); + assert!( + !path_is_dev_nest(path), + "root path must not be identified as dev nest" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 73f260bd09..e3e15aa0ed 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -1,8 +1,9 @@ --- name: buzz-cli description: > - Buzz CLI for relay operations: messaging, channels, DMs, users, workflows, - feed, reactions, canvas, social, repos, uploads, and agent memory. + Buzz CLI for relay operations: owner-reviewed agent drafts, messaging, + channels, DMs, users, workflows, feed, reactions, canvas, social, repos, + uploads, and agent memory. version: 1 --- @@ -14,8 +15,32 @@ version: 1 `BUZZ_RELAY_URL` defaults to `http://localhost:3000`. In development, the user may need to set this to a staging or production relay URL. +`BUZZ_AUTH_TAG` is required for `buzz agents draft-create` and `buzz agents draft-update` because those commands send owner-reviewed Desktop drafts. If missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. + Run the bundled CLI with `--help` and ` --help` to discover all flags, arguments, and usage. This skill documents only what `--help` cannot tell you. +## Conversational Agent Management + +When someone naturally asks to create an agent, ask for at most two things: the agent's **name** and **what it should do day-to-day**. Turn the user's rough purpose into the system prompt yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the request is genuinely ambiguous. Then run: + +```bash +buzz agents draft-create \ + --channel \ + --display-name "Research helper" \ + --system-prompt "Find reliable sources and summarize them concisely." +``` + +Use the UUID from the current Buzz `[Context]`; do not ask the user for it. Do not ask about runtime, provider, model, credentials, environment variables, or access. Desktop uses the machine's real defaults, and new agents start as **Only me**. The command sends an encrypted draft to the owner's Desktop. It does not create the agent until the owner reviews and saves the form, so report the result as “ready for review,” never “created.” + +For an explicit change to an existing personal agent, use: + +```bash +buzz agents draft-update --channel --agent-name "Current name" \ + --system-prompt "Updated instructions" +``` + +Run `buzz agents draft-update --help` for optional runtime, provider, model, rename, and access changes. Prefer these CLI commands over any legacy MCP agent-management tools. + ## Git Repositories Buzz hosts real git repos, and **you can own one yourself** — no human key needed. `repos create` signs the announcement with *your* key, so the repo is owned by whoever runs it; the owner segment in the clone URL is your own pubkey (hex, not a username). Git auth is automatic: the harness configures the `git-credential-nostr` helper, so plain `git clone`/`push`/`pull` against `/git//` just work over NIP-98 — never put a private key on a git command line. Announce with `repos create --id --clone /git//`, then `git remote add origin ` and `git push -u origin main` (the relay seeds an empty repo on announce, so it's immediately pushable). Requires git 2.46+ for the credential protocol. @@ -26,7 +51,7 @@ Output varies by command group — `--help` shows flags but not response shapes. **Read commands** (messages, channels, users, feed, workflows): normalized JSON arrays with `sig` stripped. Fields: `{id, pubkey, kind, content, created_at, tags}` for events; command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), workflows (`{workflow_id, content, created_at, pubkey}`). -**Write commands**: all return `{event_id, accepted, message}`. Create commands add the generated entity ID: `channels create` → `channel_id`, `dms open` → `dm_id`, `workflows create` → `workflow_id`. +**Write commands**: all return `{event_id, accepted, message}`. Create commands add the generated entity ID: `channels create` → `channel_id`, `dms open` → `dm_id`, `workflows create` → `workflow_id`. Agent draft commands add `{request_id, action, saved: false}` because they only open an owner-reviewed Desktop draft. **Exceptions to the above patterns:** diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs deleted file mode 100644 index 4668a36362..0000000000 --- a/desktop/src-tauri/src/managed_agents/persona_card.rs +++ /dev/null @@ -1,997 +0,0 @@ -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use png::Decoder; -use serde::Serialize; -use serde_json::Value; -use std::io::{Cursor, Read}; - -#[derive(Debug, Clone, Serialize)] -pub struct ParsedPersonaPreview { - pub display_name: String, - pub system_prompt: String, - pub avatar_data_url: Option, - pub avatar_ref: Option, - pub runtime: Option, - pub model: Option, - pub provider: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub name_pool: Vec, - pub source_file: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ParsePersonaFilesResult { - pub personas: Vec, - pub skipped: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SkippedFile { - pub source_file: String, - pub reason: String, -} - -const MAX_ZIP_ENTRIES: usize = 50; -const MAX_ZIP_DECOMPRESSED: usize = 100 * 1024 * 1024; - -pub fn parse_png_persona(png_bytes: &[u8]) -> Result { - let decoder = Decoder::new(Cursor::new(png_bytes)); - let reader = decoder - .read_info() - .map_err(|e| format!("Invalid PNG: {e}"))?; - let info = reader.info(); - - let mut buzz_text: Option<&str> = None; - let mut chara_text: Option<&str> = None; - - for chunk in &info.uncompressed_latin1_text { - match chunk.keyword.as_str() { - "buzz_persona_pkg" if buzz_text.is_none() => buzz_text = Some(&chunk.text), - "chara" | "ccv3" if chara_text.is_none() => chara_text = Some(&chunk.text), - _ => {} - } - } - - let fields = if let Some(text) = buzz_text { - parse_buzz_payload(text)? - } else if let Some(text) = chara_text { - parse_chara_payload(text)? - } else { - return Err("This image doesn't contain persona data.".to_string()); - }; - - // For PNG persona cards, the avatar is the image itself — override - // whatever avatarUrl the embedded JSON metadata might contain. - let avatar_data_url = Some(format!( - "data:image/png;base64,{}", - STANDARD.encode(png_bytes) - )); - - Ok(ParsedPersonaPreview { - display_name: fields.display_name, - system_prompt: fields.system_prompt, - avatar_data_url, - avatar_ref: None, - runtime: fields.runtime, - model: fields.model, - provider: fields.provider, - name_pool: fields.name_pool, - source_file: String::new(), - }) -} - -fn decode_b64_json(b64: &str) -> Result { - let bytes = STANDARD - .decode(b64.trim()) - .map_err(|e| format!("Invalid base64: {e}"))?; - serde_json::from_slice(&bytes).map_err(|e| format!("Invalid JSON: {e}")) -} - -/// Extracted fields from a Buzz persona JSON payload. -struct BuzzPersonaFields { - display_name: String, - system_prompt: String, - avatar_url: Option, - runtime: Option, - model: Option, - provider: Option, - name_pool: Vec, -} - -/// Extract and validate fields from a Buzz persona JSON value -/// (shared by both the PNG tEXt-chunk path and the standalone JSON path). -fn extract_buzz_fields(v: &Value) -> Result { - let version = v.get("version").and_then(|v| v.as_u64()).unwrap_or(0); - if version != 1 { - return Err(format!("Unsupported persona version: {version}")); - } - let name = v - .get("displayName") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - let prompt = v - .get("systemPrompt") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if name.is_empty() { - return Err("displayName is empty".to_string()); - } - if prompt.is_empty() { - return Err("systemPrompt is empty".to_string()); - } - let avatar_url = v - .get("avatarUrl") - .and_then(|v| v.as_str()) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - // Read "runtime" with backward-compat fallback to legacy "provider" key. - let runtime = v - .get("runtime") - .or_else(|| v.get("provider")) - .and_then(|v| v.as_str()) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - let model = v - .get("model") - .and_then(|v| v.as_str()) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - // "llmProvider" is the LLM inference provider (e.g. "databricks", "anthropic"). - // Distinct from "runtime" (the ACP harness) and from the legacy "provider" key - // (which mapped to runtime for backward compat). - let provider = v - .get("llmProvider") - .and_then(|v| v.as_str()) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - let name_pool = v - .get("namePool") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|item| item.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - }) - .unwrap_or_default(); - Ok(BuzzPersonaFields { - display_name: name, - system_prompt: prompt, - avatar_url, - runtime, - model, - provider, - name_pool, - }) -} - -fn parse_buzz_payload(b64: &str) -> Result { - let v = decode_b64_json(b64)?; - extract_buzz_fields(&v) -} - -fn parse_chara_payload(b64: &str) -> Result { - let v = decode_b64_json(b64)?; - let data = v.get("data").ok_or("Missing 'data' in chara payload")?; - let name = data - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - let mut prompt = data - .get("system_prompt") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if prompt.is_empty() { - prompt = data - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - } - if name.is_empty() { - return Err("Chara card has no name".to_string()); - } - if prompt.is_empty() { - return Err("Chara card has no system_prompt or description".to_string()); - } - Ok(BuzzPersonaFields { - display_name: name, - system_prompt: prompt, - avatar_url: None, - runtime: None, - model: None, - provider: None, - name_pool: Vec::new(), - }) -} - -pub fn parse_json_persona(json_bytes: &[u8]) -> Result { - let v: Value = serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?; - let fields = extract_buzz_fields(&v)?; - - Ok(ParsedPersonaPreview { - display_name: fields.display_name, - system_prompt: fields.system_prompt, - avatar_data_url: fields.avatar_url, - avatar_ref: None, - runtime: fields.runtime, - model: fields.model, - provider: fields.provider, - name_pool: fields.name_pool, - source_file: String::new(), - }) -} - -pub fn encode_persona_json( - display_name: &str, - system_prompt: &str, - avatar_url: Option<&str>, - runtime: Option<&str>, - model: Option<&str>, - provider: Option<&str>, - name_pool: &[String], -) -> Result, String> { - let mut map = serde_json::Map::new(); - map.insert("version".to_string(), serde_json::json!(1)); - map.insert("displayName".to_string(), serde_json::json!(display_name)); - map.insert("systemPrompt".to_string(), serde_json::json!(system_prompt)); - if let Some(url) = avatar_url { - map.insert("avatarUrl".to_string(), serde_json::json!(url)); - } - if let Some(r) = runtime { - map.insert("runtime".to_string(), serde_json::json!(r)); - } - if let Some(m) = model { - map.insert("model".to_string(), serde_json::json!(m)); - } - if let Some(p) = provider { - map.insert("llmProvider".to_string(), serde_json::json!(p)); - } - if !name_pool.is_empty() { - map.insert("namePool".to_string(), serde_json::json!(name_pool)); - } - - serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize JSON: {e}")) -} - -/// Parse a `.persona.md` file into a `ParsedPersonaPreview`. -pub fn parse_md_persona(md_bytes: &[u8]) -> Result { - let content = - std::str::from_utf8(md_bytes).map_err(|e| format!("Invalid UTF-8 in .persona.md: {e}"))?; - let config = buzz_persona_pkg::persona::parse_persona_md(content) - .map_err(|e| format!("Failed to parse .persona.md: {e}"))?; - - // Split "provider:model" into separate fields for the preview. - let model = match config.model.as_deref() { - Some(s) if !s.is_empty() => { - let (_prov, id) = buzz_persona_pkg::persona::split_model(s); - Some(id.to_owned()) - } - _ => None, - }; - - Ok(ParsedPersonaPreview { - display_name: config.display_name, - system_prompt: config.prompt, - avatar_data_url: None, // .persona.md avatars are paths, not data URIs - avatar_ref: config.avatar, - runtime: config.runtime, - model, - provider: None, // .persona.md format does not carry llmProvider - name_pool: Vec::new(), - source_file: String::new(), - }) -} - -/// Detect whether a ZIP archive is a persona pack (has `.plugin/plugin.json`). -/// If so, resolve it and return previews for all personas in the pack. -/// Find `.plugin/plugin.json` in a directory. Returns the parent of `.plugin/`. -/// Checks root and root/* only (matches pack detection scope in parse_zip_personas). -pub fn find_plugin_json(root: &std::path::Path) -> Option { - // Root level: .plugin/plugin.json - if root.join(".plugin").join("plugin.json").exists() { - return Some(root.to_path_buf()); - } - // One folder deep: /.plugin/plugin.json (common zip layout) - for entry in std::fs::read_dir(root).ok()?.flatten() { - if entry.file_type().ok()?.is_dir() { - let child = entry.path(); - if child.join(".plugin").join("plugin.json").exists() { - return Some(child); - } - } - } - None -} - -pub fn parse_zip_pack(zip_bytes: &[u8]) -> Result { - // Extract to a temp directory, resolve the pack, convert to previews. - let tmp = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?; - let cursor = Cursor::new(zip_bytes); - let mut archive = - zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid ZIP archive: {e}"))?; - - // Extract all files with safe path handling. - let mut total_decompressed: usize = 0; - for i in 0..archive.len() { - let mut entry = archive - .by_index(i) - .map_err(|e| format!("Failed to read ZIP entry: {e}"))?; - - // enclosed_name() returns None for paths with traversal components - // (.., absolute paths, Windows drive prefixes). This is the canonical - // safe extraction check from the zip crate. - let safe_name = match entry.enclosed_name() { - Some(name) => name.to_path_buf(), - None => continue, // path traversal — skip - }; - let name_str = safe_name.to_string_lossy(); - if name_str.starts_with("__MACOSX/") || name_str.contains("/._") { - continue; // macOS metadata - } - - let out_path = tmp.path().join(&safe_name); - if entry.is_dir() { - std::fs::create_dir_all(&out_path).map_err(|e| format!("Failed to create dir: {e}"))?; - } else { - if let Some(parent) = out_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create parent dir: {e}"))?; - } - let mut data = Vec::new(); - loop { - let mut chunk = [0u8; 8192]; - let n = entry - .read(&mut chunk) - .map_err(|e| format!("Read error: {e}"))?; - if n == 0 { - break; - } - total_decompressed += n; - if total_decompressed > MAX_ZIP_DECOMPRESSED { - return Err("ZIP decompressed content exceeds 100MB limit".to_string()); - } - data.extend_from_slice(&chunk[..n]); - } - std::fs::write(&out_path, &data) - .map_err(|e| format!("Failed to write {}: {e}", name_str))?; - } - } - - // Find the pack root by locating .plugin/plugin.json in the extracted tree. - // Handles: root-level (.plugin/plugin.json), single folder (my-pack/.plugin/...), - // or deeper nesting (foo/bar/.plugin/...). - let pack_root = find_plugin_json(tmp.path()).ok_or_else(|| { - "ZIP detected as pack but .plugin/plugin.json not found after extraction".to_string() - })?; - - // Resolve the pack from the extracted directory. - let resolved = buzz_persona_pkg::resolve::resolve_pack(&pack_root) - .map_err(|e| format!("Pack validation failed: {e}"))?; - - let personas: Vec = resolved - .personas - .iter() - .map(|p| ParsedPersonaPreview { - display_name: p.display_name.clone(), - system_prompt: p.system_prompt.clone(), - avatar_data_url: None, - avatar_ref: p.avatar.clone(), - runtime: p.runtime.clone(), - model: p.model.clone(), - provider: None, // persona packs do not carry llmProvider - name_pool: Vec::new(), - source_file: format!("{} ({})", p.name, resolved.name), - }) - .collect(); - - Ok(ParsePersonaFilesResult { - personas, - skipped: vec![], - }) -} - -pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result { - let cursor = Cursor::new(zip_bytes); - let mut archive = - zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid ZIP archive: {e}"))?; - - // Detect persona pack BEFORE entry limit — packs may have many files. - // Only match root-level or one-folder-deep (matches find_plugin_json scope). - let is_pack = (0..archive.len()).any(|i| { - archive - .by_index(i) - .ok() - .map(|e| { - let name = e.name().trim_start_matches('/'); - // Root: ".plugin/plugin.json" - // One folder deep: "my-pack/.plugin/plugin.json" - name == ".plugin/plugin.json" - || name - .strip_suffix("/.plugin/plugin.json") - .map(|prefix| !prefix.contains('/')) - .unwrap_or(false) - }) - .unwrap_or(false) - }); - if is_pack { - return parse_zip_pack(zip_bytes); - } - - // Entry limit only applies to loose-persona zips (not packs). - if archive.len() > MAX_ZIP_ENTRIES { - return Err(format!( - "ZIP contains too many entries ({}, max {MAX_ZIP_ENTRIES})", - archive.len() - )); - } - - let mut personas = Vec::new(); - let mut skipped = Vec::new(); - let mut total_decompressed: usize = 0; - let mut has_valid_file = false; - - for i in 0..archive.len() { - let mut entry = archive - .by_index(i) - .map_err(|e| format!("Failed to read ZIP entry: {e}"))?; - - let raw_name = entry.name().to_string(); - - // Sanitize path - let name = raw_name.trim_start_matches('/'); - if name.contains("..") { - skipped.push(SkippedFile { - source_file: raw_name.clone(), - reason: "Path traversal detected".to_string(), - }); - continue; - } - - // Skip macOS resource fork metadata (e.g. __MACOSX/._file.json) - if name.starts_with("__MACOSX/") || name.contains("/._") || name.starts_with("._") { - continue; - } - - let lower = name.to_ascii_lowercase(); - let is_png = lower.ends_with(".png"); - let is_json = lower.ends_with(".json"); - let is_md = lower.ends_with(".persona.md"); - - if !is_png && !is_json && !is_md { - skipped.push(SkippedFile { - source_file: raw_name, - reason: "Not a .png, .json, or .persona.md file".to_string(), - }); - continue; - } - - has_valid_file = true; - - // Read with cumulative size limit - let mut data = Vec::new(); - loop { - let mut chunk = [0u8; 8192]; - let n = entry - .read(&mut chunk) - .map_err(|e| format!("Read error: {e}"))?; - if n == 0 { - break; - } - total_decompressed += n; - if total_decompressed > MAX_ZIP_DECOMPRESSED { - return Err("ZIP decompressed content exceeds 100MB limit".to_string()); - } - data.extend_from_slice(&chunk[..n]); - } - - let parse_result = if is_md { - parse_md_persona(&data) - } else if is_json { - parse_json_persona(&data) - } else { - parse_png_persona(&data) - }; - - match parse_result { - Ok(mut preview) => { - preview.source_file = raw_name; - personas.push(preview); - } - Err(reason) => { - skipped.push(SkippedFile { - source_file: raw_name, - reason, - }); - } - } - } - - if !has_valid_file { - return Err("No persona files found (expected .png, .json, or .persona.md).".to_string()); - } - - Ok(ParsePersonaFilesResult { personas, skipped }) -} - -#[cfg(test)] -mod tests { - use super::*; - use png::{BitDepth, ColorType, Encoder}; - use std::io::Write; - use zip::write::{SimpleFileOptions, ZipWriter}; - - /// Helper: build a minimal valid PNG with a custom tEXt chunk. - fn make_png_with_text(keyword: &str, text: &str) -> Vec { - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - enc.add_text_chunk(keyword.to_string(), text.to_string()) - .unwrap(); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - buf - } - - /// Helper: build a PNG with a buzz_persona_pkg tEXt chunk for the given name/prompt. - fn make_test_persona_png(name: &str, prompt: &str) -> Vec { - let payload = serde_json::json!({ - "version": 1, - "displayName": name, - "systemPrompt": prompt, - }); - let b64 = STANDARD.encode(payload.to_string().as_bytes()); - make_png_with_text("buzz_persona_pkg", &b64) - } - - /// Helper: build a plain PNG with no metadata. - fn make_plain_png() -> Vec { - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - buf - } - - /// Helper: create a ZIP from name→data pairs. - fn make_test_zip(entries: &[(&str, &[u8])]) -> Vec { - let mut buf = Cursor::new(Vec::new()); - let mut zip = ZipWriter::new(&mut buf); - let options = SimpleFileOptions::default(); - for (name, data) in entries { - zip.start_file(*name, options).unwrap(); - zip.write_all(data).unwrap(); - } - zip.finish().unwrap(); - buf.into_inner() - } - - #[test] - fn parse_png_round_trip() { - let png = make_test_persona_png("George Costanza", "You are George."); - let result = parse_png_persona(&png).unwrap(); - assert_eq!(result.display_name, "George Costanza"); - assert_eq!(result.system_prompt, "You are George."); - assert!(result - .avatar_data_url - .unwrap() - .starts_with("data:image/png;base64,")); - } - - #[test] - fn parse_png_no_metadata() { - let png = make_plain_png(); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("doesn't contain persona data")); - } - - #[test] - fn parse_png_unknown_version() { - let payload = serde_json::json!({"version": 99, "displayName": "X", "systemPrompt": "Y"}); - let b64 = STANDARD.encode(payload.to_string().as_bytes()); - let png = make_png_with_text("buzz_persona_pkg", &b64); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("Unsupported persona version")); - } - - #[test] - fn parse_png_malformed_base64() { - let png = make_png_with_text("buzz_persona_pkg", "!!!not-base64!!!"); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("Invalid base64")); - } - - #[test] - fn parse_png_malformed_json() { - let b64 = STANDARD.encode(b"not json at all"); - let png = make_png_with_text("buzz_persona_pkg", &b64); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("Invalid JSON")); - } - - #[test] - fn parse_png_empty_fields() { - let payload = serde_json::json!({"version": 1, "displayName": "", "systemPrompt": "Y"}); - let b64 = STANDARD.encode(payload.to_string().as_bytes()); - let png = make_png_with_text("buzz_persona_pkg", &b64); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("displayName is empty")); - } - - #[test] - fn parse_png_chara_fallback() { - let chara = serde_json::json!({ - "spec": "chara_card_v2", - "spec_version": "2.0", - "data": { - "name": "Kramer", - "system_prompt": "You are Kramer.", - "description": "" - } - }); - let b64 = STANDARD.encode(chara.to_string().as_bytes()); - let png = make_png_with_text("chara", &b64); - let result = parse_png_persona(&png).unwrap(); - assert_eq!(result.display_name, "Kramer"); - assert_eq!(result.system_prompt, "You are Kramer."); - } - - #[test] - fn parse_png_chara_ignored_when_buzz_present() { - // Build a PNG with both buzz_persona_pkg and chara chunks. - let buzz = serde_json::json!({"version": 1, "displayName": "Buzz Name", "systemPrompt": "Buzz prompt"}); - let chara = serde_json::json!({ - "spec": "chara_card_v2", "spec_version": "2.0", - "data": {"name": "Chara Name", "system_prompt": "Chara prompt", "description": ""} - }); - let buzz_b64 = STANDARD.encode(buzz.to_string().as_bytes()); - let chara_b64 = STANDARD.encode(chara.to_string().as_bytes()); - - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - enc.add_text_chunk("buzz_persona_pkg".to_string(), buzz_b64) - .unwrap(); - enc.add_text_chunk("chara".to_string(), chara_b64).unwrap(); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - - let result = parse_png_persona(&buf).unwrap(); - assert_eq!(result.display_name, "Buzz Name"); - assert_eq!(result.system_prompt, "Buzz prompt"); - } - - #[test] - fn parse_zip_valid_pack() { - let p1 = make_test_persona_png("Alice", "Prompt A"); - let p2 = make_test_persona_png("Bob", "Prompt B"); - let p3 = make_test_persona_png("Carol", "Prompt C"); - let zip = make_test_zip(&[("alice.png", &p1), ("bob.png", &p2), ("carol.png", &p3)]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 3); - assert!(result.skipped.is_empty()); - assert_eq!(result.personas[0].source_file, "alice.png"); - } - - #[test] - fn parse_zip_mixed() { - let valid1 = make_test_persona_png("Alice", "Prompt A"); - let valid2 = make_test_persona_png("Bob", "Prompt B"); - let bad_png = make_plain_png(); // no metadata - let zip = make_test_zip(&[ - ("alice.png", &valid1), - ("bob.png", &valid2), - ("bad.png", &bad_png), - ("readme.txt", b"hello"), - ]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - assert_eq!(result.skipped.len(), 2); - } - - #[test] - fn parse_zip_no_pngs() { - let zip = make_test_zip(&[("readme.txt", b"hello"), ("data.csv", b"a,b")]); - let err = parse_zip_personas(&zip).unwrap_err(); - assert!(err.contains("No persona files found")); - } - - #[test] - fn parse_zip_exceeds_entry_limit() { - let png = make_test_persona_png("X", "Y"); - let entries: Vec<(String, &[u8])> = (0..51) - .map(|i| (format!("{i}.png"), png.as_slice())) - .collect(); - let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, d)| (n.as_str(), *d)).collect(); - let zip = make_test_zip(&refs); - let err = parse_zip_personas(&zip).unwrap_err(); - assert!(err.contains("too many entries")); - } - - #[test] - fn parse_zip_path_traversal() { - let valid = make_test_persona_png("Safe", "Prompt"); - let evil = make_test_persona_png("Evil", "Prompt"); - let zip = make_test_zip(&[("safe.png", &valid), ("../evil.png", &evil)]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 1); - assert_eq!(result.skipped.len(), 1); - assert!(result.skipped[0].reason.contains("Path traversal")); - } - - #[test] - fn parse_png_duplicate_chunks() { - // Two buzz_persona_pkg chunks — should use the first and ignore the second. - let payload1 = - serde_json::json!({"version": 1, "displayName": "First", "systemPrompt": "Prompt 1"}); - let payload2 = - serde_json::json!({"version": 1, "displayName": "Second", "systemPrompt": "Prompt 2"}); - let b64_1 = STANDARD.encode(payload1.to_string().as_bytes()); - let b64_2 = STANDARD.encode(payload2.to_string().as_bytes()); - - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - enc.add_text_chunk("buzz_persona_pkg".to_string(), b64_1) - .unwrap(); - enc.add_text_chunk("buzz_persona_pkg".to_string(), b64_2) - .unwrap(); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - - let result = parse_png_persona(&buf).unwrap(); - assert_eq!(result.display_name, "First"); - assert_eq!(result.system_prompt, "Prompt 1"); - } - - #[test] - fn parse_zip_exceeds_size_limit() { - // Create a ZIP with entries whose cumulative decompressed size exceeds 100MB. - let mut zip_buf = Cursor::new(Vec::new()); - { - let mut zip = ZipWriter::new(&mut zip_buf); - let options = SimpleFileOptions::default(); - zip.start_file("big.png", options).unwrap(); - let chunk = vec![0u8; 1024 * 1024]; // 1 MB - for _ in 0..101 { - zip.write_all(&chunk).unwrap(); - } - zip.finish().unwrap(); - } - let zip_bytes = zip_buf.into_inner(); - let err = parse_zip_personas(&zip_bytes).unwrap_err(); - assert!(err.contains("exceeds 100MB")); - } - - #[test] - fn parse_json_round_trip() { - let bytes = encode_persona_json( - "Ada Lovelace", - "You are Ada.", - Some("https://example.com/ada.png"), - None, - None, - None, - &[], - ) - .unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Ada Lovelace"); - assert_eq!(result.system_prompt, "You are Ada."); - assert_eq!( - result.avatar_data_url.as_deref(), - Some("https://example.com/ada.png") - ); - assert!(result.source_file.is_empty()); - } - - #[test] - fn parse_md_carries_avatar_ref() { - let md = b"---\nname: goosey\ndisplay_name: Goosey\navatar: app-avatar:gloopies-19\ndescription: A goose persona.\n---\nYou are Goosey.\n"; - let result = parse_md_persona(md).unwrap(); - - assert_eq!(result.display_name, "Goosey"); - assert!(result.avatar_data_url.is_none()); - assert_eq!(result.avatar_ref.as_deref(), Some("app-avatar:gloopies-19")); - } - - #[test] - fn parse_json_round_trip_no_avatar() { - let bytes = - encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Bob"); - assert_eq!(result.system_prompt, "You are Bob."); - assert!(result.avatar_data_url.is_none()); - } - - #[test] - fn parse_json_round_trip_data_uri_avatar() { - let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="; - let bytes = encode_persona_json( - "Carol", - "You are Carol.", - Some(data_uri), - None, - None, - None, - &[], - ) - .unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Carol"); - assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri)); - } - - #[test] - fn parse_json_round_trip_with_runtime_and_model() { - let bytes = encode_persona_json( - "Agent Smith", - "You are an agent.", - None, - Some("goose"), - Some("claude-sonnet-4"), - None, - &[], - ) - .unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Agent Smith"); - assert_eq!(result.system_prompt, "You are an agent."); - assert!(result.avatar_data_url.is_none()); - assert_eq!(result.runtime.as_deref(), Some("goose")); - assert_eq!(result.model.as_deref(), Some("claude-sonnet-4")); - } - - #[test] - fn parse_json_round_trip_without_runtime_and_model() { - let bytes = - encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Bob"); - assert!(result.runtime.is_none()); - assert!(result.model.is_none()); - } - - #[test] - fn parse_json_backward_compat_no_runtime_model_fields() { - // Simulate a legacy persona JSON without runtime/model fields - let json = serde_json::json!({ - "version": 1, - "displayName": "Legacy Persona", - "systemPrompt": "Old school prompt" - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Legacy Persona"); - assert_eq!(result.system_prompt, "Old school prompt"); - assert!(result.runtime.is_none()); - assert!(result.model.is_none()); - } - - #[test] - fn parse_json_backward_compat_legacy_provider_key() { - // A JSON card written with the old "provider" key should still parse. - let json = serde_json::json!({ - "version": 1, - "displayName": "Legacy Agent", - "systemPrompt": "Old prompt", - "provider": "goose" - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.runtime.as_deref(), Some("goose")); - } - - #[test] - fn parse_json_invalid_version() { - let json = serde_json::json!({ - "version": 99, - "displayName": "X", - "systemPrompt": "Y" - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let err = parse_json_persona(&bytes).unwrap_err(); - assert!(err.contains("Unsupported persona version")); - } - - #[test] - fn parse_json_empty_fields() { - let json_empty_name = serde_json::json!({ - "version": 1, - "displayName": "", - "systemPrompt": "Y" - }); - let err = parse_json_persona(&serde_json::to_vec(&json_empty_name).unwrap()).unwrap_err(); - assert!(err.contains("displayName is empty")); - - let json_empty_prompt = serde_json::json!({ - "version": 1, - "displayName": "X", - "systemPrompt": "" - }); - let err = parse_json_persona(&serde_json::to_vec(&json_empty_prompt).unwrap()).unwrap_err(); - assert!(err.contains("systemPrompt is empty")); - } - - #[test] - fn parse_json_malformed() { - let err = parse_json_persona(b"not json at all").unwrap_err(); - assert!(err.contains("Invalid JSON")); - } - - #[test] - fn parse_zip_with_json() { - let j1 = encode_persona_json("Alice", "Prompt A", None, None, None, None, &[]).unwrap(); - let j2 = encode_persona_json("Bob", "Prompt B", None, None, None, None, &[]).unwrap(); - let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - assert!(result.skipped.is_empty()); - assert_eq!(result.personas[0].display_name, "Alice"); - assert_eq!(result.personas[1].display_name, "Bob"); - } - - #[test] - fn parse_zip_mixed_png_and_json() { - let png = make_test_persona_png("PngPersona", "PNG prompt"); - let json = - encode_persona_json("JsonPersona", "JSON prompt", None, None, None, None, &[]).unwrap(); - let zip = make_test_zip(&[ - ("persona.png", &png), - ("persona.json", &json), - ("readme.txt", b"hello"), - ]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - // readme.txt should be skipped - assert_eq!(result.skipped.len(), 1); - assert!(result.skipped[0] - .reason - .contains("Not a .png, .json, or .persona.md file")); - } - - #[test] - fn parse_zip_ignores_macos_resource_forks() { - let j1 = - encode_persona_json("Frank", "You are Frank.", None, None, None, None, &[]).unwrap(); - let j2 = - encode_persona_json("Jackie", "You are Jackie.", None, None, None, None, &[]).unwrap(); - let zip = make_test_zip(&[ - ("frank-costanza.persona.json", &j1), - ("jackie-chiles.persona.json", &j2), - ("__MACOSX/._frank-costanza.persona.json", b"\x00\x05\x16"), - ("__MACOSX/._jackie-chiles.persona.json", b"\x00\x05\x16"), - ]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - // macOS resource forks should be silently ignored, not skipped with errors - assert!(result.skipped.is_empty()); - } -} diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 7ce7ec0fdb..02b2815230 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -1,4 +1,4 @@ -//! Serialize `PersonaRecord` ↔ kind:30175 persona events and publish/fetch via relay. +//! Serialize `AgentDefinition` ↔ kind:30175 persona events and publish/fetch via relay. //! //! Persona events are NIP-33 parameterized replaceable events keyed by //! `(pubkey, kind, d_tag)` where `d_tag` is the plaintext persona slug. @@ -9,7 +9,7 @@ use buzz_core_pkg::kind::KIND_PERSONA; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; -use super::PersonaRecord; +use super::{AgentDefinition, ManagedAgentRecord}; use crate::app_state::AppState; /// The JSON body stored in a persona event's content field. @@ -23,7 +23,12 @@ use crate::app_state::AppState; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PersonaEventContent { pub display_name: String, - pub system_prompt: String, + /// Optional since the unified agent model (NIP-AP revision): a definition + /// can be pure configuration. Writers emit `Some` whenever the record has + /// a prompt (including the empty string) so pre-revision content bytes — + /// and therefore `persona_content_hash` — are unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub avatar_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -34,9 +39,18 @@ pub struct PersonaEventContent { pub provider: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub name_pool: Vec, + /// Definition-level defaults copied onto instances at creation + /// (NIP-AP behavioral fields). Absent = defer to client defaults; + /// `skip_serializing_if` keeps pre-revision hashes stable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respond_to: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parallelism: Option, } -/// Derive the d-tag (persona slug) from a `PersonaRecord`. +/// Derive the d-tag (persona slug) from a `AgentDefinition`. /// /// Uses `source_team_persona_slug` if available, otherwise falls back to `id`, /// then normalizes to the NIP-AP slug grammar (`^[a-z0-9][a-z0-9_-]{0,63}$`, @@ -48,7 +62,7 @@ pub struct PersonaEventContent { /// /// Both the outbound publish and the inbound match key route through this fn, /// so the normalized value is consistent in both directions and cannot drift. -pub fn persona_d_tag(record: &PersonaRecord) -> String { +pub fn persona_d_tag(record: &AgentDefinition) -> String { let raw = record .source_team_persona_slug .as_deref() @@ -112,19 +126,13 @@ pub fn monotonic_created_at(prior_head_created_at: Option) -> nostr::Timest nostr::Timestamp::from(now.max(floor) as u64) } -/// Build a kind:30175 event from a `PersonaRecord`. +/// Build a kind:30175 event from a `AgentDefinition`. /// /// Returns an unsigned `EventBuilder` — the caller signs and submits. -pub fn build_persona_event(record: &PersonaRecord) -> Result { - let content = PersonaEventContent { - display_name: record.display_name.clone(), - avatar_url: record.avatar_url.clone(), - system_prompt: record.system_prompt.clone(), - runtime: record.runtime.clone(), - model: record.model.clone(), - provider: record.provider.clone(), - name_pool: record.name_pool.clone(), - }; +pub fn build_persona_event(record: &AgentDefinition) -> Result { + // Single projection point — persona_event_content owns the field mapping + // (and the hash-stability rules that come with it). + let content = persona_event_content(record); let content_json = serde_json::to_string(&content) .map_err(|e| format!("failed to serialize persona content: {e}"))?; @@ -147,10 +155,10 @@ pub fn build_persona_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result Result { +pub fn persona_from_event(event: &nostr::Event) -> Result { let d_tag = event .tags .iter() @@ -169,11 +177,11 @@ pub fn persona_from_event(event: &nostr::Event) -> Result let created_at = event.created_at.to_human_datetime(); - Ok(PersonaRecord { + Ok(AgentDefinition { id: d_tag.clone(), display_name: content.display_name, avatar_url: content.avatar_url, - system_prompt: content.system_prompt, + system_prompt: content.system_prompt.unwrap_or_default(), runtime: content.runtime, model: content.model, provider: content.provider, @@ -183,6 +191,9 @@ pub fn persona_from_event(event: &nostr::Event) -> Result source_team: None, source_team_persona_slug: Some(d_tag), env_vars: BTreeMap::new(), + respond_to: content.respond_to, + respond_to_allowlist: content.respond_to_allowlist, + parallelism: content.parallelism, created_at: created_at.clone(), updated_at: created_at, }) @@ -212,7 +223,8 @@ pub async fn flush_pending_events( state: &AppState, ) -> Result { use crate::managed_agents::retention::{ - get_pending_sync, get_retained_event, mark_synced, open_retention_db, + deferred_behind_failed_tombstone, get_pending_sync, get_retained_event, mark_synced, + open_retention_db, }; use nostr::JsonUtil; @@ -222,7 +234,12 @@ pub async fn flush_pending_events( }; // connection dropped before any .await let mut flushed = 0u32; + let mut failed_tombstones: std::collections::HashSet<(String, String)> = + std::collections::HashSet::new(); for row in pending { + if deferred_behind_failed_tombstone(row.kind, &row.pubkey, &row.d_tag, &failed_tombstones) { + continue; // its tombstone failed this sweep; next sweep re-orders them + } // Re-read immediately before publishing; the row may have been edited // or deleted since the pending snapshot above. let current = { @@ -243,6 +260,9 @@ pub async fn flush_pending_events( .await .is_err() { + if current.kind == 5 { + failed_tombstones.insert((current.pubkey.clone(), current.d_tag.clone())); + } continue; // relay unreachable — stays pending for the next sweep } @@ -275,18 +295,29 @@ pub fn persona_content_hash(content: &PersonaEventContent) -> String { hex::encode(digest) } -/// Project a `PersonaRecord` onto the content fields published in persona +/// Project a `AgentDefinition` onto the content fields published in persona /// events and engrams. Centralizes the field mapping so a new persona field is /// added in exactly one place. -pub fn persona_event_content(record: &PersonaRecord) -> PersonaEventContent { +pub fn persona_event_content(record: &AgentDefinition) -> PersonaEventContent { PersonaEventContent { display_name: record.display_name.clone(), avatar_url: record.avatar_url.clone(), - system_prompt: record.system_prompt.clone(), + // Always Some — including for an empty prompt — so pre-revision + // records serialize byte-identically and persona_content_hash is + // stable across the upgrade (drift badges must not flip). + system_prompt: Some(record.system_prompt.clone()), runtime: record.runtime.clone(), model: record.model.clone(), provider: record.provider.clone(), name_pool: record.name_pool.clone(), + // NIP-AP behavioral defaults: live since the create-path unification + // (B5) — carried on AgentDefinition in wire shape and copied verbatim. + // Quad-absent records serialize identically to the reserved era, so + // persona_content_hash is stable across the activation (guarded by + // `quad_absent_definition_hash_stable_across_activation`). + respond_to: record.respond_to.clone(), + respond_to_allowlist: record.respond_to_allowlist.clone(), + parallelism: record.parallelism, } } @@ -299,330 +330,126 @@ pub struct PersonaSnapshot { pub system_prompt: Option, pub model: Option, pub provider: Option, - /// Persona env layered under the agent's own overrides (agent wins). This - /// is the complete env map the agent spawns with — no live persona lookup. - pub env_vars: BTreeMap, + /// Preferred ACP runtime ID, copied verbatim from the persona (including + /// `None`). Unlike `model`/`provider`, there is no record-fallback: the + /// materialized instance `runtime` must mirror the definition so that + /// definition edits propagate on the next spawn rather than being silently + /// shadowed by the stale materialized value. + pub runtime: Option, /// `persona_content_hash` of the persona at snapshot time; the drift basis. pub source_version: String, } +/// Apply persona-wins-when-set precedence for a single optional string field. +/// +/// Returns the persona's value when it is non-`None` and non-whitespace-only; +/// otherwise falls back to the record's value with the same blank filter applied. +/// Returns `None` only when both are blank — a genuinely unconfigured field stays +/// unconfigured. +/// +/// This is the single source of truth for the precedence rule used by +/// `persona_snapshot_with_agent_config_fallback`, `build_deploy_payload`, and +/// `resolve_effective_prompt_model_provider` so the three paths cannot drift. +pub fn persona_field_with_record_fallback( + persona_value: Option<&str>, + record_value: Option<&str>, +) -> Option { + let non_blank = |v: Option<&str>| v.filter(|s| !s.trim().is_empty()).map(str::to_owned); + non_blank(persona_value).or_else(|| non_blank(record_value)) +} + /// Build the pinned snapshot for an agent created from `persona`. /// -/// `agent_env_overrides` are the agent's own env vars (persona-independent); -/// they win over persona env on key collision, matching spawn-time precedence -/// (persona env < agent env). The persona's `system_prompt` is always present, -/// so it is wrapped in `Some`. -pub fn persona_snapshot( - persona: &PersonaRecord, - agent_env_overrides: &BTreeMap, -) -> PersonaSnapshot { - let mut env_vars = persona.env_vars.clone(); - for (key, value) in agent_env_overrides { - env_vars.insert(key.clone(), value.clone()); - } +/// The persona's `system_prompt` is always present, so it is wrapped in +/// `Some`. Env vars are deliberately absent: `record.env_vars` holds agent +/// overrides only, and the live persona env is merged underneath at read +/// time (spawn / readiness / deploy) — never snapshotted. +pub fn persona_snapshot(persona: &AgentDefinition) -> PersonaSnapshot { PersonaSnapshot { system_prompt: Some(persona.system_prompt.clone()), model: persona.model.clone(), provider: persona.provider.clone(), - env_vars, + runtime: persona.runtime.clone(), source_version: persona_content_hash(&persona_event_content(persona)), } } -#[cfg(test)] -mod tests { - use super::*; +/// Build the pinned snapshot for an **existing** agent record being re-snapshotted +/// from its linked persona (on spawn or app-launch restore). +/// +/// Precedence rule: when the persona sets `model` or `provider` (non-`None`, non-empty), +/// the persona wins — this is the expected inheritance. When the persona leaves +/// these fields blank (`None` or empty string), the agent record's own values are +/// preserved instead. This prevents a persona with no configured model/provider from +/// clobbering a value the user already set on the agent, which would trap the agent +/// in a permanent "needs configuration" loop that users cannot escape. +/// +/// `source_version` is always updated to the current persona content hash so the +/// drift badge clears correctly even when model/provider are not touched. +/// +/// Env vars are not part of the snapshot: `record.env_vars` (agent overrides) +/// is left untouched and the live persona env is merged underneath at read time. +/// +/// The two fields (`model`, `provider`) are independent: a persona that sets only +/// `model` wins on `model` while the agent's `provider` is preserved, and vice versa. +pub fn persona_snapshot_with_agent_config_fallback( + persona: &AgentDefinition, + current_agent_model: Option<&str>, + current_agent_provider: Option<&str>, +) -> PersonaSnapshot { + // Delegate system_prompt and source_version to persona_snapshot so future + // PersonaSnapshot field additions stay automatically consistent. + let base = persona_snapshot(persona); - fn sample_persona() -> PersonaRecord { - PersonaRecord { - id: "test-persona".to_string(), - display_name: "Test Persona".to_string(), - avatar_url: Some("https://example.com/avatar.png".to_string()), - system_prompt: "You are a test assistant.".to_string(), - runtime: Some("goose".to_string()), - model: Some("claude-opus-4".to_string()), - provider: Some("anthropic".to_string()), - name_pool: vec!["Alpha".to_string(), "Beta".to_string()], - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: Some("test-slug".to_string()), - env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]), - created_at: "2025-01-01T00:00:00Z".to_string(), - updated_at: "2025-01-01T00:00:00Z".to_string(), - } - } + // Apply the shared precedence rule: persona wins when non-blank, else + // the agent record's value is preserved so a configured agent stays configured. + let model = persona_field_with_record_fallback(base.model.as_deref(), current_agent_model); + let provider = + persona_field_with_record_fallback(base.provider.as_deref(), current_agent_provider); - #[test] - fn monotonic_created_at_bumps_past_head() { - // No head: uses now (floor 0). - let now = nostr::Timestamp::now().as_secs() as i64; - let none = monotonic_created_at(None).as_secs() as i64; - assert!(none >= now, "no-head write must be >= now"); - - // Head in the FUTURE (same-second or clock-skewed): must bump to head+1, - // never reuse now (which would be <= head and lose the NIP-33 tiebreak). - let future_head = now + 1000; - let bumped = monotonic_created_at(Some(future_head)).as_secs() as i64; - assert_eq!( - bumped, - future_head + 1, - "must supersede a future head by +1" - ); - - // Head in the PAST: now already exceeds it, so now wins. - let past = monotonic_created_at(Some(now - 1000)).as_secs() as i64; - assert!(past >= now, "past head must not drag created_at backward"); - } - - #[test] - fn d_tag_uses_slug_when_available() { - let record = sample_persona(); - assert_eq!(persona_d_tag(&record), "test-slug"); - } - - #[test] - fn d_tag_falls_back_to_id() { - let mut record = sample_persona(); - record.source_team_persona_slug = None; - assert_eq!(persona_d_tag(&record), "test-persona"); - } - - /// Mirror of the relay slug grammar (`ingest.rs:923` `^[a-z0-9][a-z0-9_-]{0,63}$`) - /// so the normalization tests assert what the relay actually enforces. - fn passes_relay_slug_grammar(d: &str) -> bool { - let bytes = d.as_bytes(); - !d.is_empty() - && d.len() <= 64 - && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) - && bytes[1..] - .iter() - .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') - } - - #[test] - fn d_tag_normalizes_pack_slug_to_relay_grammar() { - // The cited failing cases: mixed-case and leading-underscore pack slugs - // that the relay rejects un-normalized → pending forever. - for (raw, expected) in [ - ("CodeReviewer", "codereviewer"), - ("_ops", "a_ops"), - ("Code-Reviewer", "code-reviewer"), - ("UPPER_snake", "upper_snake"), - ("-leading-dash", "a-leading-dash"), - ] { - let mut record = sample_persona(); - record.source_team_persona_slug = Some(raw.to_string()); - let d = persona_d_tag(&record); - assert_eq!(d, expected, "normalization of {raw:?}"); - assert!( - passes_relay_slug_grammar(&d), - "normalized {raw:?} -> {d:?} still fails the relay grammar" - ); - } - } - - #[test] - fn d_tag_already_valid_slug_is_unchanged() { - // In-app personas use a lowercase-hex UUID id — already valid, must pass - // through untouched (no spurious coordinate change on existing data). - let mut record = sample_persona(); - record.source_team_persona_slug = None; - record.id = "11111111-2222-3333-4444-555555555555".to_string(); - let d = persona_d_tag(&record); - assert_eq!(d, "11111111-2222-3333-4444-555555555555"); - assert!(passes_relay_slug_grammar(&d)); - } - - #[test] - fn build_persona_event_produces_correct_kind() { - let record = sample_persona(); - let builder = build_persona_event(&record).unwrap(); - let keys = nostr::Keys::generate(); - let event = builder.sign_with_keys(&keys).unwrap(); - assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA); - } - - #[test] - fn round_trip_serialization() { - let record = sample_persona(); - let builder = build_persona_event(&record).unwrap(); - let keys = nostr::Keys::generate(); - let event = builder.sign_with_keys(&keys).unwrap(); - - let restored = persona_from_event(&event).unwrap(); - assert_eq!(restored.id, "test-slug"); - assert_eq!(restored.display_name, "Test Persona"); - assert_eq!( - restored.avatar_url, - Some("https://example.com/avatar.png".to_string()) - ); - assert_eq!(restored.system_prompt, "You are a test assistant."); - assert_eq!(restored.runtime, Some("goose".to_string())); - assert_eq!(restored.model, Some("claude-opus-4".to_string())); - assert_eq!(restored.provider, Some("anthropic".to_string())); - assert_eq!(restored.name_pool, vec!["Alpha", "Beta"]); - // env_vars are not included in public persona events (secrets travel - // via NIP-44-encrypted engrams only). - assert!(restored.env_vars.is_empty()); - assert_eq!( - restored.source_team_persona_slug, - Some("test-slug".to_string()) - ); - assert!(!restored.is_builtin); - assert!(restored.is_active); - } - - /// NIP-AP reference vector (Event 1, `docs/nips/NIP-AP.md:195-207`): the - /// serialized content bytes MUST match the spec exactly, byte-for-byte. - /// serde emits fields in declaration order, so this pins the content - /// encoding — and therefore the NIP-01 event id — for cross-implementation - /// interop. The field order is `display_name, system_prompt, avatar_url, - /// runtime, model, provider, name_pool`. - #[test] - fn content_matches_nip_ap_vector() { - // Exact body from NIP-AP.md Event 1 (no trailing whitespace, no BOM). - const VECTOR: &str = r#"{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}"#; - - let content = PersonaEventContent { - display_name: "Test Agent".to_string(), - system_prompt: "You are a test assistant.".to_string(), - avatar_url: Some("https://example.com/avatar.png".to_string()), - runtime: Some("goose".to_string()), - model: Some("claude-opus-4".to_string()), - provider: Some("anthropic".to_string()), - name_pool: vec!["Alpha".to_string(), "Beta".to_string()], - }; - assert_eq!( - serde_json::to_string(&content).unwrap(), - VECTOR, - "serialized content drifted from the NIP-AP Event 1 vector" - ); - - // An event built from this content carries the byte-exact vector as its - // signed content, so a second implementer following the spec computes - // the same NIP-01 id. - let record = PersonaRecord { - id: "test-agent".to_string(), - display_name: "Test Agent".to_string(), - avatar_url: Some("https://example.com/avatar.png".to_string()), - system_prompt: "You are a test assistant.".to_string(), - runtime: Some("goose".to_string()), - model: Some("claude-opus-4".to_string()), - provider: Some("anthropic".to_string()), - name_pool: vec!["Alpha".to_string(), "Beta".to_string()], - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - env_vars: BTreeMap::new(), - created_at: "2025-01-01T00:00:00Z".to_string(), - updated_at: "2025-01-01T00:00:00Z".to_string(), - }; - let event = build_persona_event(&record) - .unwrap() - .sign_with_keys(&nostr::Keys::generate()) - .unwrap(); - assert_eq!(event.content, VECTOR); - } - - #[test] - fn round_trip_minimal_persona() { - let record = PersonaRecord { - id: "minimal".to_string(), - display_name: "Minimal".to_string(), - avatar_url: None, - system_prompt: "Hello".to_string(), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - is_builtin: true, - is_active: false, - source_team: Some("team-1".to_string()), - source_team_persona_slug: None, - env_vars: BTreeMap::new(), - created_at: "2025-01-01T00:00:00Z".to_string(), - updated_at: "2025-01-01T00:00:00Z".to_string(), - }; - - let builder = build_persona_event(&record).unwrap(); - let keys = nostr::Keys::generate(); - let event = builder.sign_with_keys(&keys).unwrap(); - - let restored = persona_from_event(&event).unwrap(); - assert_eq!(restored.id, "minimal"); - assert_eq!(restored.display_name, "Minimal"); - assert_eq!(restored.avatar_url, None); - assert_eq!(restored.runtime, None); - assert_eq!(restored.model, None); - assert_eq!(restored.provider, None); - assert!(restored.name_pool.is_empty()); - assert!(restored.env_vars.is_empty()); - // Deserialized persona is always non-builtin and active - assert!(!restored.is_builtin); - assert!(restored.is_active); - } - - #[test] - fn build_persona_delete_has_single_a_tag_no_e_tag() { - const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - let builder = build_persona_delete("test-slug", OWNER).unwrap(); - let keys = nostr::Keys::generate(); - let event = builder.sign_with_keys(&keys).unwrap(); - - assert_eq!(event.kind, Kind::Custom(5)); - - let a_tags: Vec<&[String]> = event - .tags - .iter() - .map(|t| t.as_slice()) - .filter(|v| v.first().map(String::as_str) == Some("a")) - .collect(); - assert_eq!(a_tags.len(), 1); - assert_eq!(a_tags[0][1], format!("{KIND_PERSONA}:{OWNER}:test-slug")); - - // An e-tag would route to the event-id deletion path and leave the - // replaceable coordinate live — the tombstone must carry none. - assert!(event - .tags - .iter() - .all(|t| t.as_slice().first().map(String::as_str) != Some("e"))); - } - - #[test] - fn persona_content_hash_is_deterministic() { - let content = PersonaEventContent { - display_name: "Test".to_string(), - avatar_url: None, - system_prompt: "Hello".to_string(), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - }; - let hash1 = persona_content_hash(&content); - let hash2 = persona_content_hash(&content); - assert_eq!(hash1, hash2); - assert_eq!(hash1.len(), 64); // SHA-256 hex - } - - #[test] - fn persona_content_hash_changes_on_edit() { - let content1 = PersonaEventContent { - display_name: "Test".to_string(), - avatar_url: None, - system_prompt: "Hello".to_string(), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - }; - let mut content2 = content1.clone(); - content2.system_prompt = "Goodbye".to_string(); - assert_ne!( - persona_content_hash(&content1), - persona_content_hash(&content2) - ); + PersonaSnapshot { + model, + provider, + ..base } } + +/// Re-pin `record` to `persona`: build the snapshot (via +/// [`persona_snapshot_with_agent_config_fallback`], so blank persona +/// `model`/`provider` preserve the record's own values) and mirror it onto the +/// record — the definition quad (`system_prompt`/`model`/`provider`/`runtime`), +/// the env-override self-heal, and the `persona_source_version` drift basis. +/// +/// This is the single apply used by every snapshot-apply site: the spawn +/// re-pin (`start_local_agent_with_preflight`), the launch backfill and +/// restore re-snapshot (`restore.rs`), and the prospective re-snapshot inside +/// `spawn_config_hash` — so a future `PersonaSnapshot` field addition +/// propagates to all of them at once. +/// +/// Deliberately does NOT touch `updated_at`: persistence stamps are the +/// caller's concern, and `spawn_config_hash` (which applies this to a clone) +/// must stay pure. +pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDefinition) { + let snapshot = persona_snapshot_with_agent_config_fallback( + persona, + record.model.as_deref(), // fallback: record.model + record.provider.as_deref(), // fallback: record.provider + ); + if let Some(prompt) = snapshot.system_prompt { + record.system_prompt = Some(prompt); + } + record.model = snapshot.model; + record.provider = snapshot.provider; + record.runtime = snapshot.runtime; + // env_vars stay overrides-only. Self-heal records written before the env + // refresh: persona env used to be baked into `record.env_vars`, turning + // inherited values into pseudo-overrides that shadow later persona edits. + // An override equal to the persona's current value is indistinguishable + // from inheritance, so drop it and let the live merge supply it. + record + .env_vars + .retain(|k, v| persona.env_vars.get(k) != Some(v)); + record.persona_source_version = Some(snapshot.source_version); +} +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs new file mode 100644 index 0000000000..d61a6bc8a7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -0,0 +1,802 @@ +use super::*; + +fn sample_persona() -> AgentDefinition { + AgentDefinition { + id: "test-persona".to_string(), + display_name: "Test Persona".to_string(), + avatar_url: Some("https://example.com/avatar.png".to_string()), + system_prompt: "You are a test assistant.".to_string(), + runtime: Some("goose".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + name_pool: vec!["Alpha".to_string(), "Beta".to_string()], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: Some("test-slug".to_string()), + env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + } +} + +#[test] +fn monotonic_created_at_bumps_past_head() { + // No head: uses now (floor 0). + let now = nostr::Timestamp::now().as_secs() as i64; + let none = monotonic_created_at(None).as_secs() as i64; + assert!(none >= now, "no-head write must be >= now"); + + // Head in the FUTURE (same-second or clock-skewed): must bump to head+1, + // never reuse now (which would be <= head and lose the NIP-33 tiebreak). + let future_head = now + 1000; + let bumped = monotonic_created_at(Some(future_head)).as_secs() as i64; + assert_eq!( + bumped, + future_head + 1, + "must supersede a future head by +1" + ); + + // Head in the PAST: now already exceeds it, so now wins. + let past = monotonic_created_at(Some(now - 1000)).as_secs() as i64; + assert!(past >= now, "past head must not drag created_at backward"); +} + +#[test] +fn d_tag_uses_slug_when_available() { + let record = sample_persona(); + assert_eq!(persona_d_tag(&record), "test-slug"); +} + +#[test] +fn d_tag_falls_back_to_id() { + let mut record = sample_persona(); + record.source_team_persona_slug = None; + assert_eq!(persona_d_tag(&record), "test-persona"); +} + +/// Mirror of the relay slug grammar (`ingest.rs:923` `^[a-z0-9][a-z0-9_-]{0,63}$`) +/// so the normalization tests assert what the relay actually enforces. +fn passes_relay_slug_grammar(d: &str) -> bool { + let bytes = d.as_bytes(); + !d.is_empty() + && d.len() <= 64 + && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) + && bytes[1..] + .iter() + .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') +} + +#[test] +fn d_tag_normalizes_pack_slug_to_relay_grammar() { + // The cited failing cases: mixed-case and leading-underscore pack slugs + // that the relay rejects un-normalized → pending forever. + for (raw, expected) in [ + ("CodeReviewer", "codereviewer"), + ("_ops", "a_ops"), + ("Code-Reviewer", "code-reviewer"), + ("UPPER_snake", "upper_snake"), + ("-leading-dash", "a-leading-dash"), + ] { + let mut record = sample_persona(); + record.source_team_persona_slug = Some(raw.to_string()); + let d = persona_d_tag(&record); + assert_eq!(d, expected, "normalization of {raw:?}"); + assert!( + passes_relay_slug_grammar(&d), + "normalized {raw:?} -> {d:?} still fails the relay grammar" + ); + } +} + +#[test] +fn d_tag_already_valid_slug_is_unchanged() { + // In-app personas use a lowercase-hex UUID id — already valid, must pass + // through untouched (no spurious coordinate change on existing data). + let mut record = sample_persona(); + record.source_team_persona_slug = None; + record.id = "11111111-2222-3333-4444-555555555555".to_string(); + let d = persona_d_tag(&record); + assert_eq!(d, "11111111-2222-3333-4444-555555555555"); + assert!(passes_relay_slug_grammar(&d)); +} + +#[test] +fn build_persona_event_produces_correct_kind() { + let record = sample_persona(); + let builder = build_persona_event(&record).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA); +} + +#[test] +fn round_trip_serialization() { + let record = sample_persona(); + let builder = build_persona_event(&record).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + + let restored = persona_from_event(&event).unwrap(); + assert_eq!(restored.id, "test-slug"); + assert_eq!(restored.display_name, "Test Persona"); + assert_eq!( + restored.avatar_url, + Some("https://example.com/avatar.png".to_string()) + ); + assert_eq!(restored.system_prompt, "You are a test assistant."); + assert_eq!(restored.runtime, Some("goose".to_string())); + assert_eq!(restored.model, Some("claude-opus-4".to_string())); + assert_eq!(restored.provider, Some("anthropic".to_string())); + assert_eq!(restored.name_pool, vec!["Alpha", "Beta"]); + // env_vars are not included in public persona events (secrets travel + // via NIP-44-encrypted engrams only). + assert!(restored.env_vars.is_empty()); + assert_eq!( + restored.source_team_persona_slug, + Some("test-slug".to_string()) + ); + assert!(!restored.is_builtin); + assert!(restored.is_active); +} + +/// NIP-AP reference vector (Event 1, `docs/nips/NIP-AP.md:195-207`): the +/// serialized content bytes MUST match the spec exactly, byte-for-byte. +/// serde emits fields in declaration order, so this pins the content +/// encoding — and therefore the NIP-01 event id — for cross-implementation +/// interop. The field order is `display_name, system_prompt, avatar_url, +/// runtime, model, provider, name_pool`. +#[test] +fn content_matches_nip_ap_vector() { + // Exact body from NIP-AP.md Event 1 (no trailing whitespace, no BOM). + const VECTOR: &str = r#"{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}"#; + + let content = PersonaEventContent { + display_name: "Test Agent".to_string(), + system_prompt: Some("You are a test assistant.".to_string()), + avatar_url: Some("https://example.com/avatar.png".to_string()), + runtime: Some("goose".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + name_pool: vec!["Alpha".to_string(), "Beta".to_string()], + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + }; + assert_eq!( + serde_json::to_string(&content).unwrap(), + VECTOR, + "serialized content drifted from the NIP-AP Event 1 vector" + ); + + // Hash invariance across the unified-model widening: REAL pre-revision + // content bytes (fixture string, not a round-trip through the new + // struct) must parse and re-serialize byte-identically, so + // persona_content_hash — the drift-badge basis — is unchanged on + // upgrade. A bare Option serializing "system_prompt":null would flip + // every persona's hash fleet-wide. + let parsed: PersonaEventContent = serde_json::from_str(VECTOR).unwrap(); + assert_eq!( + serde_json::to_string(&parsed).unwrap(), + VECTOR, + "pre-revision content bytes must survive a parse/serialize round-trip unchanged" + ); + assert_eq!( + persona_content_hash(&parsed), + { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(VECTOR.as_bytes())) + }, + "persona_content_hash of pre-revision bytes must equal the direct digest" + ); + + // Hash stability, adversarial shapes: the empty prompt and the + // minimal old-writer body (display_name + system_prompt only) are the + // two easiest regressions if the projection or skip attributes ever + // change. + const EMPTY_PROMPT: &str = r#"{"display_name":"X","system_prompt":""}"#; + let parsed: PersonaEventContent = serde_json::from_str(EMPTY_PROMPT).unwrap(); + assert_eq!(serde_json::to_string(&parsed).unwrap(), EMPTY_PROMPT); + const MINIMAL: &str = r#"{"display_name":"Minimal","system_prompt":"Hello."}"#; + let parsed: PersonaEventContent = serde_json::from_str(MINIMAL).unwrap(); + assert_eq!(serde_json::to_string(&parsed).unwrap(), MINIMAL); + + // An event built from this content carries the byte-exact vector as its + // signed content, so a second implementer following the spec computes + // the same NIP-01 id. + let record = AgentDefinition { + id: "test-agent".to_string(), + display_name: "Test Agent".to_string(), + avatar_url: Some("https://example.com/avatar.png".to_string()), + system_prompt: "You are a test assistant.".to_string(), + runtime: Some("goose".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + name_pool: vec!["Alpha".to_string(), "Beta".to_string()], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + let event = build_persona_event(&record) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + assert_eq!(event.content, VECTOR); +} + +#[test] +fn round_trip_minimal_persona() { + let record = AgentDefinition { + id: "minimal".to_string(), + display_name: "Minimal".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: true, + is_active: false, + source_team: Some("team-1".to_string()), + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + + let builder = build_persona_event(&record).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + + let restored = persona_from_event(&event).unwrap(); + assert_eq!(restored.id, "minimal"); + assert_eq!(restored.display_name, "Minimal"); + assert_eq!(restored.avatar_url, None); + assert_eq!(restored.runtime, None); + assert_eq!(restored.model, None); + assert_eq!(restored.provider, None); + assert!(restored.name_pool.is_empty()); + assert!(restored.env_vars.is_empty()); + // Deserialized persona is always non-builtin and active + assert!(!restored.is_builtin); + assert!(restored.is_active); +} + +#[test] +fn build_persona_delete_has_single_a_tag_no_e_tag() { + const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + let builder = build_persona_delete("test-slug", OWNER).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + + assert_eq!(event.kind, Kind::Custom(5)); + + let a_tags: Vec<&[String]> = event + .tags + .iter() + .map(|t| t.as_slice()) + .filter(|v| v.first().map(String::as_str) == Some("a")) + .collect(); + assert_eq!(a_tags.len(), 1); + assert_eq!(a_tags[0][1], format!("{KIND_PERSONA}:{OWNER}:test-slug")); + + // An e-tag would route to the event-id deletion path and leave the + // replaceable coordinate live — the tombstone must carry none. + assert!(event + .tags + .iter() + .all(|t| t.as_slice().first().map(String::as_str) != Some("e"))); +} + +/// NIP-AP behavioral defaults are LIVE since B5 (create-path +/// unification): the wire fields are carried on AgentDefinition in wire +/// shape and re-emitted verbatim by the projection — a foreign +/// definition's behavioral values now survive a local +/// edit-and-republish cycle. This test replaces +/// `behavioral_defaults_are_staged_not_applied` (the staging lock), +/// whose deliberate removal was pinned in the B5 review gates. +#[test] +fn behavioral_defaults_survive_record_round_trip() { + const FOREIGN: &str = r#"{"display_name":"F","system_prompt":"p","respond_to":"anyone","respond_to_allowlist":["deadbeef"],"parallelism":4}"#; + let parsed: PersonaEventContent = serde_json::from_str(FOREIGN).unwrap(); + // Wire layer preserves the fields... + assert_eq!(parsed.respond_to.as_deref(), Some("anyone")); + assert_eq!(parsed.parallelism, Some(4)); + // ...and the record round-trip now carries them through. + let record = persona_from_event_content_for_test(parsed); + let reprojected = persona_event_content(&record); + assert_eq!(reprojected.respond_to.as_deref(), Some("anyone")); + assert_eq!(reprojected.respond_to_allowlist, vec!["deadbeef"]); + assert_eq!(reprojected.parallelism, Some(4)); +} + +/// B5 hash row 1: a quad-absent definition's content bytes — and +/// therefore `persona_content_hash` — are identical before and after +/// quad activation. Pre-activation the projection hardcoded `None`; +/// post-activation it copies the record's (absent) quad. Both serialize +/// to the same bytes via `skip_serializing_if`, so no drift badge flips +/// and no republish wave fires for quad-absent definitions. +#[test] +fn quad_absent_definition_hash_stable_across_activation() { + let record = AgentDefinition { + id: "quad-absent".to_string(), + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: Some("goose".to_string()), + model: Some("gpt-oss".to_string()), + provider: None, + name_pool: vec!["nib".to_string()], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }; + let live = persona_event_content(&record); + // The reserved-era projection: identical fields, quad hardcoded off. + let reserved_era = PersonaEventContent { + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + ..live.clone() + }; + assert_eq!( + serde_json::to_string(&live).unwrap(), + serde_json::to_string(&reserved_era).unwrap(), + "quad-absent projection must serialize byte-identically to the reserved era" + ); + assert_eq!( + persona_content_hash(&live), + persona_content_hash(&reserved_era) + ); +} + +/// Test-only bridge: build an AgentDefinition from parsed content the same +/// way `persona_from_event` maps fields, without needing a signed event. +fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDefinition { + AgentDefinition { + id: "staged".to_string(), + display_name: content.display_name, + avatar_url: content.avatar_url, + system_prompt: content.system_prompt.unwrap_or_default(), + runtime: content.runtime, + model: content.model, + provider: content.provider, + name_pool: content.name_pool, + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: content.respond_to, + respond_to_allowlist: content.respond_to_allowlist, + parallelism: content.parallelism, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } +} + +#[test] +fn persona_content_hash_is_deterministic() { + let content = PersonaEventContent { + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: Some("Hello".to_string()), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + }; + let hash1 = persona_content_hash(&content); + let hash2 = persona_content_hash(&content); + assert_eq!(hash1, hash2); + assert_eq!(hash1.len(), 64); // SHA-256 hex +} + +#[test] +fn persona_content_hash_changes_on_edit() { + let content1 = PersonaEventContent { + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: Some("Hello".to_string()), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + }; + let mut content2 = content1.clone(); + content2.system_prompt = Some("Goodbye".to_string()); + assert_ne!( + persona_content_hash(&content1), + persona_content_hash(&content2) + ); +} + +// ── persona_field_with_record_fallback ──────────────────────────────────── + +#[test] +fn field_fallback_persona_present_wins() { + assert_eq!( + persona_field_with_record_fallback(Some("persona-model"), Some("record-model")), + Some("persona-model".to_owned()), + ); +} + +#[test] +fn field_fallback_persona_blank_uses_record() { + assert_eq!( + persona_field_with_record_fallback(None, Some("record-model")), + Some("record-model".to_owned()), + ); + assert_eq!( + persona_field_with_record_fallback(Some(" "), Some("record-model")), + Some("record-model".to_owned()), + ); +} + +#[test] +fn field_fallback_both_blank_is_none() { + assert_eq!(persona_field_with_record_fallback(None, None), None); + assert_eq!(persona_field_with_record_fallback(Some(""), Some("")), None); +} + +#[test] +fn field_fallback_record_blank_is_none() { + assert_eq!( + persona_field_with_record_fallback(None, Some(" ")), + None, + "whitespace-only record value must also be treated as blank" + ); +} + +// ── PersonaSnapshot.runtime ─────────────────────────────────────────────── + +/// (b) The snapshot carries the persona's runtime VERBATIM — including None, +/// which clears a stale materialized value on the instance record. Unlike +/// model/provider, runtime does not fall back to the record's own value: +/// instances have no user-owned runtime, so the definition must stay +/// authoritative. +#[test] +fn snapshot_runtime_verbatim_from_persona() { + let persona = sample_persona(); // runtime = Some("goose") + let snap = persona_snapshot_with_agent_config_fallback(&persona, Some("gpt-4"), Some("openai")); + assert_eq!( + snap.runtime.as_deref(), + Some("goose"), + "persona runtime Some must be copied verbatim into snapshot" + ); + + let mut no_runtime = sample_persona(); + no_runtime.runtime = None; + let snap = + persona_snapshot_with_agent_config_fallback(&no_runtime, Some("gpt-4"), Some("openai")); + assert_eq!( + snap.runtime, None, + "persona runtime None must produce None snapshot (clears stale materialized value)" + ); +} + +// ── persona_snapshot_with_agent_config_fallback ──────────────────────────── + +/// Helper: a persona with no model/provider configured. +fn blank_model_persona() -> AgentDefinition { + AgentDefinition { + model: None, + provider: None, + ..sample_persona() + } +} + +/// (a) Persona leaves model/provider blank, agent record has values → +/// record values preserved AND source_version still updated to current hash. +#[test] +fn fallback_preserves_record_values_when_persona_blank() { + let persona = blank_model_persona(); + let expected_version = persona_content_hash(&persona_event_content(&persona)); + + let snapshot = + persona_snapshot_with_agent_config_fallback(&persona, Some("gpt-4o"), Some("openai")); + + assert_eq!( + snapshot.model.as_deref(), + Some("gpt-4o"), + "blank persona model must fall back to agent record value" + ); + assert_eq!( + snapshot.provider.as_deref(), + Some("openai"), + "blank persona provider must fall back to agent record value" + ); + assert_eq!( + snapshot.source_version, expected_version, + "source_version must still reflect current persona hash" + ); +} + +/// (b) Persona has model/provider set → persona wins over agent record. +#[test] +fn fallback_persona_wins_when_set() { + let persona = sample_persona(); // has model=Some("claude-opus-4"), provider=Some("anthropic") + + let snapshot = persona_snapshot_with_agent_config_fallback( + &persona, + Some("gpt-4o"), // agent had a different model + Some("openai"), // agent had a different provider + ); + + assert_eq!( + snapshot.model.as_deref(), + Some("claude-opus-4"), + "persona model must win when persona has a value" + ); + assert_eq!( + snapshot.provider.as_deref(), + Some("anthropic"), + "persona provider must win when persona has a value" + ); +} + +/// (c) Both blank → snapshot keeps None; a genuinely unconfigured agent +/// stays unconfigured (no fabricated values). +#[test] +fn fallback_both_blank_stays_none() { + let persona = blank_model_persona(); + + let snapshot = persona_snapshot_with_agent_config_fallback( + &persona, None, // agent also has no model + None, // agent also has no provider + ); + + assert!( + snapshot.model.is_none(), + "neither persona nor agent has model — snapshot must be None" + ); + assert!( + snapshot.provider.is_none(), + "neither persona nor agent has provider — snapshot must be None" + ); +} + +/// Whitespace-only values on the persona are treated as blank; agent +/// fallback applies. +#[test] +fn fallback_treats_whitespace_only_persona_value_as_blank() { + let mut persona = sample_persona(); + persona.model = Some(" ".to_string()); + persona.provider = Some("\t".to_string()); + + let snapshot = persona_snapshot_with_agent_config_fallback( + &persona, + Some("claude-opus-4"), + Some("anthropic"), + ); + + assert_eq!( + snapshot.model.as_deref(), + Some("claude-opus-4"), + "whitespace-only persona model must be treated as blank" + ); + assert_eq!( + snapshot.provider.as_deref(), + Some("anthropic"), + "whitespace-only persona provider must be treated as blank" + ); +} + +/// Cross-field independence: persona sets model but not provider → model +/// comes from persona, provider falls back to the record. This is the +/// practically common case (model-only personas). +#[test] +fn fallback_persona_model_set_provider_blank_uses_record_provider() { + let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") + persona.provider = None; // blank provider on persona + + let snapshot = persona_snapshot_with_agent_config_fallback( + &persona, + Some("gpt-4o"), // record model (should be overridden by persona) + Some("openai"), // record provider (should be preserved) + ); + + assert_eq!( + snapshot.model.as_deref(), + Some("claude-opus-4"), + "persona model must win when persona has a value" + ); + assert_eq!( + snapshot.provider.as_deref(), + Some("openai"), + "record provider must be used when persona provider is blank" + ); +} + +/// Inverse: persona sets provider but not model → provider comes from +/// persona, model falls back to the record. +#[test] +fn fallback_persona_provider_set_model_blank_uses_record_model() { + let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") + persona.model = None; // blank model on persona + + let snapshot = persona_snapshot_with_agent_config_fallback( + &persona, + Some("gpt-4o"), // record model (should be preserved) + Some("openai"), // record provider (should be overridden by persona) + ); + + assert_eq!( + snapshot.model.as_deref(), + Some("gpt-4o"), + "record model must be used when persona model is blank" + ); + assert_eq!( + snapshot.provider.as_deref(), + Some("anthropic"), + "persona provider must win when persona has a value" + ); +} + +// Gated off Windows for the same reason as `archive::real_relay`: +// `build_app_state()` pulls native DLLs unavailable in the Windows CI +// runner. This stub-relay test is hermetic (localhost axum) otherwise. +#[cfg(not(target_os = "windows"))] +mod flush_barrier { + use super::*; + use crate::app_state::build_app_state; + use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, + RetainedEvent, + }; + use nostr::JsonUtil; + + /// Stub relay: `POST /events` rejects kind:5 with HTTP 500, accepts + /// everything else. Returns the HTTP base URL. + async fn spawn_stub_relay() -> String { + use axum::{http::StatusCode, routing::post, Router}; + + let app = Router::new().route( + "/events", + post(|body: String| async move { + let event: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + if event.get("kind").and_then(serde_json::Value::as_u64) == Some(5) { + return (StatusCode::INTERNAL_SERVER_ERROR, String::new()); + } + ( + StatusCode::OK, + serde_json::json!({ + "event_id": event.get("id").and_then(serde_json::Value::as_str).unwrap_or(""), + "accepted": true, + "message": "" + }) + .to_string(), + ) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stub relay"); + let addr = listener.local_addr().expect("stub relay addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + format!("http://{addr}") + } + + fn retain_signed( + conn: &rusqlite::Connection, + keys: &nostr::Keys, + kind: u32, + retention_d_tag: &str, + builder: nostr::EventBuilder, + created_at: i64, + ) { + let event = builder.sign_with_keys(keys).expect("sign test event"); + retain_event( + conn, + &RetainedEvent { + kind, + pubkey: keys.public_key().to_hex(), + d_tag: retention_d_tag.to_string(), + content: event.content.to_string(), + created_at, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .expect("retain test event"); + } + + /// The mid-sweep barrier: a tombstone the relay rejects must defer its + /// own replacement to the next sweep (still pending, not counted as + /// flushed) while unrelated rows in the same sweep publish normally. + /// Failing toward stay-deleted is the safe direction — the deferred + /// replacement can never be wiped by its own late tombstone. + #[tokio::test] + async fn failed_tombstone_defers_replacement_within_sweep() { + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("retention.db"); + + { + let conn = open_retention_db(&db_path).expect("open db"); + // Tombstone (publishes first, relay rejects it). + retain_signed( + &conn, + &keys, + 5, + &tombstone_retention_d_tag(KIND_PERSONA, "covered"), + build_persona_delete("covered", &pubkey).unwrap(), + 1000, + ); + // Its replacement at the same coordinate (must defer). + retain_signed( + &conn, + &keys, + KIND_PERSONA, + "covered", + EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "{}") + .tags(vec![Tag::parse(["d", "covered"]).unwrap()]), + 2000, + ); + // Unrelated coordinate (must publish despite the barrier). + retain_signed( + &conn, + &keys, + KIND_PERSONA, + "unrelated", + EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "{}") + .tags(vec![Tag::parse(["d", "unrelated"]).unwrap()]), + 1500, + ); + } + + let state = build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); + + let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); + assert_eq!(flushed, 1, "only the unrelated row publishes"); + + let conn = open_retention_db(&db_path).expect("reopen db"); + let row = |kind: u32, d_tag: &str| { + get_retained_event(&conn, kind, &pubkey, d_tag) + .unwrap() + .unwrap() + }; + assert!( + row(5, &tombstone_retention_d_tag(KIND_PERSONA, "covered")).pending_sync, + "failed tombstone stays pending" + ); + assert!( + row(KIND_PERSONA, "covered").pending_sync, + "deferred replacement stays pending" + ); + assert!( + !row(KIND_PERSONA, "unrelated").pending_sync, + "unrelated row marked synced" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index e170b9d8af..0b05fe1811 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -1,11 +1,8 @@ -use std::{fs, path::PathBuf}; +use std::fs; use tauri::AppHandle; -use crate::{ - managed_agents::{managed_agents_base_dir, PersonaRecord}, - util::now_iso, -}; +use crate::{managed_agents::AgentDefinition, util::now_iso}; struct BuiltInPersona { id: &'static str, @@ -18,94 +15,15 @@ struct BuiltInPersona { default_active: bool, } -const FIZZ_AVATAR: &str = "data:image/png;base64,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"; +const FIZZ_AVATAR: &str = "data:image/png;base64,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"; +const HONEY_AVATAR: &str = "data:image/png;base64,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"; +const BUMBLE_AVATAR: &str = "data:image/png;base64,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"; -const PRODUCT_STRATEGIST_AVATAR: &str = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcng9IjMyIiBmaWxsPSIjMmY2ZjczIi8+PGNpcmNsZSBjeD0iOTYiIGN5PSIzMiIgcj0iMTYiIGZpbGw9IiNkZmY3ZjQiIGZpbGwtb3BhY2l0eT0iLjI4Ii8+PHBhdGggZD0iTTI0IDk0YzE0LTI1IDI3LTM3IDQwLTM3czI2IDEyIDQwIDM3IiBmaWxsPSJub25lIiBzdHJva2U9IiNkZmY3ZjQiIHN0cm9rZS13aWR0aD0iOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PHRleHQgeD0iNjQiIHk9IjcyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0iSW50ZXIsQXJpYWwsc2Fucy1zZXJpZiIgZm9udC1zaXplPSIzNCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iI2RmZjdmNCI+UFM8L3RleHQ+PC9zdmc+"; -const IMPLEMENTATION_PARTNER_AVATAR: &str = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcng9IjMyIiBmaWxsPSIjNmY0ZTlhIi8+PGNpcmNsZSBjeD0iOTYiIGN5PSIzMiIgcj0iMTYiIGZpbGw9IiNmMGU4ZmYiIGZpbGwtb3BhY2l0eT0iLjI4Ii8+PHBhdGggZD0iTTI0IDk0YzE0LTI1IDI3LTM3IDQwLTM3czI2IDEyIDQwIDM3IiBmaWxsPSJub25lIiBzdHJva2U9IiNmMGU4ZmYiIHN0cm9rZS13aWR0aD0iOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PHRleHQgeD0iNjQiIHk9IjcyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0iSW50ZXIsQXJpYWwsc2Fucy1zZXJpZiIgZm9udC1zaXplPSIzNCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iI2YwZThmZiI+SVA8L3RleHQ+PC9zdmc+"; -const QA_REVIEWER_AVATAR: &str = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcng9IjMyIiBmaWxsPSIjOWE1YTFmIi8+PGNpcmNsZSBjeD0iOTYiIGN5PSIzMiIgcj0iMTYiIGZpbGw9IiNmZmYwZGMiIGZpbGwtb3BhY2l0eT0iLjI4Ii8+PHBhdGggZD0iTTI0IDk0YzE0LTI1IDI3LTM3IDQwLTM3czI2IDEyIDQwIDM3IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYwZGMiIHN0cm9rZS13aWR0aD0iOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PHRleHQgeD0iNjQiIHk9IjcyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0iSW50ZXIsQXJpYWwsc2Fucy1zZXJpZiIgZm9udC1zaXplPSIzNCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iI2ZmZjBkYyI+UUE8L3RleHQ+PC9zdmc+"; -const WORK_COORDINATOR_AVATAR: &str = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcng9IjMyIiBmaWxsPSIjMzQ1ZjhjIi8+PGNpcmNsZSBjeD0iOTYiIGN5PSIzMiIgcj0iMTYiIGZpbGw9IiNlM2YwZmYiIGZpbGwtb3BhY2l0eT0iLjI4Ii8+PHBhdGggZD0iTTI0IDk0YzE0LTI1IDI3LTM3IDQwLTM3czI2IDEyIDQwIDM3IiBmaWxsPSJub25lIiBzdHJva2U9IiNlM2YwZmYiIHN0cm9rZS13aWR0aD0iOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PHRleHQgeD0iNjQiIHk9IjcyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0iSW50ZXIsQXJpYWwsc2Fucy1zZXJpZiIgZm9udC1zaXplPSIzNCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iI2UzZjBmZiI+V0M8L3RleHQ+PC9zdmc+"; -const SUPPORT_GUIDE_AVATAR: &str = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcng9IjMyIiBmaWxsPSIjNmE3MDMxIi8+PGNpcmNsZSBjeD0iOTYiIGN5PSIzMiIgcj0iMTYiIGZpbGw9IiNmMmY2ZDciIGZpbGwtb3BhY2l0eT0iLjI4Ii8+PHBhdGggZD0iTTI0IDk0YzE0LTI1IDI3LTM3IDQwLTM3czI2IDEyIDQwIDM3IiBmaWxsPSJub25lIiBzdHJva2U9IiNmMmY2ZDciIHN0cm9rZS13aWR0aD0iOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PHRleHQgeD0iNjQiIHk9IjcyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0iSW50ZXIsQXJpYWwsc2Fucy1zZXJpZiIgZm9udC1zaXplPSIzNCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iI2YyZjZkNyI+U0c8L3RleHQ+PC9zdmc+"; -const EXPERIMENT_DESIGNER_AVATAR: &str = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcng9IjMyIiBmaWxsPSIjOGIzZjVlIi8+PGNpcmNsZSBjeD0iOTYiIGN5PSIzMiIgcj0iMTYiIGZpbGw9IiNmZmU3ZjAiIGZpbGwtb3BhY2l0eT0iLjI4Ii8+PHBhdGggZD0iTTI0IDk0YzE0LTI1IDI3LTM3IDQwLTM3czI2IDEyIDQwIDM3IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmU3ZjAiIHN0cm9rZS13aWR0aD0iOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PHRleHQgeD0iNjQiIHk9IjcyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0iSW50ZXIsQXJpYWwsc2Fucy1zZXJpZiIgZm9udC1zaXplPSIzNCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iI2ZmZTdmMCI+RUQ8L3RleHQ+PC9zdmc+"; +const FIZZ_SYSTEM_PROMPT: &str = "You are Fizz, an energetic maker who turns ideas into action. Be upbeat, practical, and decisive. Help users plan, create, solve problems, and finish work. Add occasional bee wordplay or 🐝✨—keep it charming, never distracting."; -const FIZZ_SYSTEM_PROMPT: &str = r#"You are Fizz. You are a careful, direct engineering agent with a subtle bee theme: collaborative, industrious, and precise. Keep the bee motif light — no catchphrases, no cartoon impersonation, and no performative roleplay. Reliability beats performance theater. +const HONEY_SYSTEM_PROMPT: &str = "You are Honey, a warm and thoughtful communicator. Help users write clearly, organize ideas, brainstorm, summarize, and prepare for conversations. Be kind, creative, and concise. Add occasional bee wordplay or 🍯🐝—keep it sweet, never excessive."; -# Subagents and Peers - -Other agents are peers, not tools. Collaborate when useful, but partition ownership by file or task so two writers never edit the same path. Front-load setup before tagging someone, agree on the base and handoff contract, and integrate their results without also doing their exact work. - -Use subagents when: - -- You can decompose research into unrelated areas explored in parallel. -- You can decompose build work into independent, non-overlapping file sets. -- A task needs a long-running command while you continue other work. - -Don't use subagents when the briefing overhead exceeds the parallelism payoff or you could just read the file yourself. - -# Communication - -- Bee-themed emoji are okay, but use them sparingly — at most one when it genuinely adds warmth or clarity, and skip them entirely in serious, blocked, or failure updates. - -Your name is Fizz. You are friendly, helpful, and quietly industrious — more honeycomb than hornet."#; - -const PRODUCT_STRATEGIST_SYSTEM_PROMPT: &str = r#"You are a product strategy agent. You help turn broad ideas into clear product and design direction. - -# Focus - -Clarify the goal, identify the audience, and call out the tradeoffs that matter. Keep recommendations concrete and avoid over-polishing before the shape of the work is understood. - -# Collaboration - -Ask sharp questions when the brief is ambiguous. Once the direction is clear, summarize the intended experience and the next useful decision."#; - -const IMPLEMENTATION_PARTNER_SYSTEM_PROMPT: &str = r#"You are an implementation partner agent. You help turn scoped plans into working changes. - -# Focus - -Prefer small, direct edits that follow the existing codebase. Keep momentum, validate behavior, and flag any risky assumption before it becomes expensive. - -# Collaboration - -Give concise progress updates and keep the work easy for another engineer to review."#; - -const QA_REVIEWER_SYSTEM_PROMPT: &str = r#"You are a QA reviewer agent. You look for the ways a change might break. - -# Focus - -Inspect state transitions, empty states, permissions, accessibility, and failure paths. Prioritize issues by impact and make the evidence easy to verify. - -# Collaboration - -Be direct but constructive. When possible, suggest the smallest fix that would reduce the risk."#; - -const WORK_COORDINATOR_SYSTEM_PROMPT: &str = r#"You are a work coordination agent. You keep multi-step work organized and grounded. - -# Focus - -Track goals, dependencies, and follow-ups. Keep plans practical, name what is done, and make unresolved work visible without adding ceremony. - -# Collaboration - -Help the team stay aligned. Summarize decisions clearly and keep the next action obvious."#; - -const SUPPORT_GUIDE_SYSTEM_PROMPT: &str = r#"You are a support and onboarding agent. You help people feel oriented quickly. - -# Focus - -Explain unfamiliar flows plainly, surface the next useful step, and keep guidance reassuring without becoming verbose. - -# Collaboration - -Use a warm tone, confirm assumptions when needed, and leave people with a clear path forward."#; - -const EXPERIMENT_DESIGNER_SYSTEM_PROMPT: &str = r#"You are an experiment design agent. You help turn uncertainty into experiments. - -# Focus - -Break big questions into small trials, name what would prove or disprove an idea, and keep momentum through ambiguity. - -# Collaboration - -Stay curious, practical, and optimistic. Make the next experiment easy to try."#; +const BUMBLE_SYSTEM_PROMPT: &str = "You are Bumble, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic."; const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ BuiltInPersona { @@ -114,73 +32,32 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ avatar_url: Some(FIZZ_AVATAR), system_prompt: FIZZ_SYSTEM_PROMPT, name_pool: &[ - "Nectar", "Comet", "Bramble", "Clover", "Pollen", "Amber", "Daisy", "Mason", "Bumble", - "Thistle", "Honey", "Waxwing", "Hive", "Meadow", "Juniper", "Aster", "Sage", "Willow", - "Orchard", "Buzz", + "Nectar", "Comet", "Bramble", "Clover", "Pollen", "Amber", "Daisy", "Mason", "Thistle", + "Waxwing", "Hive", "Meadow", "Juniper", "Aster", "Sage", "Willow", "Orchard", "Buzz", ], model: None, - runtime: Some("goose"), + runtime: None, default_active: true, }, BuiltInPersona { - id: "builtin:product-strategist", - display_name: "Product Strategist", - avatar_url: Some(PRODUCT_STRATEGIST_AVATAR), - system_prompt: PRODUCT_STRATEGIST_SYSTEM_PROMPT, - name_pool: &["Product Strategist"], + id: "builtin:honey", + display_name: "Honey", + avatar_url: Some(HONEY_AVATAR), + system_prompt: HONEY_SYSTEM_PROMPT, + name_pool: &["Honey"], model: None, runtime: None, - default_active: false, + default_active: true, }, BuiltInPersona { - id: "builtin:implementation-partner", - display_name: "Implementation Partner", - avatar_url: Some(IMPLEMENTATION_PARTNER_AVATAR), - system_prompt: IMPLEMENTATION_PARTNER_SYSTEM_PROMPT, - name_pool: &["Implementation Partner"], + id: "builtin:bumble", + display_name: "Bumble", + avatar_url: Some(BUMBLE_AVATAR), + system_prompt: BUMBLE_SYSTEM_PROMPT, + name_pool: &["Bumble"], model: None, runtime: None, - default_active: false, - }, - BuiltInPersona { - id: "builtin:qa-reviewer", - display_name: "QA Reviewer", - avatar_url: Some(QA_REVIEWER_AVATAR), - system_prompt: QA_REVIEWER_SYSTEM_PROMPT, - name_pool: &["QA Reviewer"], - model: None, - runtime: None, - default_active: false, - }, - BuiltInPersona { - id: "builtin:work-coordinator", - display_name: "Work Coordinator", - avatar_url: Some(WORK_COORDINATOR_AVATAR), - system_prompt: WORK_COORDINATOR_SYSTEM_PROMPT, - name_pool: &["Work Coordinator"], - model: None, - runtime: None, - default_active: false, - }, - BuiltInPersona { - id: "builtin:support-guide", - display_name: "Support Guide", - avatar_url: Some(SUPPORT_GUIDE_AVATAR), - system_prompt: SUPPORT_GUIDE_SYSTEM_PROMPT, - name_pool: &["Support Guide"], - model: None, - runtime: None, - default_active: false, - }, - BuiltInPersona { - id: "builtin:experiment-designer", - display_name: "Experiment Designer", - avatar_url: Some(EXPERIMENT_DESIGNER_AVATAR), - system_prompt: EXPERIMENT_DESIGNER_SYSTEM_PROMPT, - name_pool: &["Experiment Designer"], - model: None, - runtime: None, - default_active: false, + default_active: true, }, ]; @@ -223,14 +100,10 @@ const RETIRED_PERSONAS: &[(&str, &str)] = &[ ), ]; -fn personas_store_path(app: &AppHandle) -> Result { - Ok(managed_agents_base_dir(app)?.join("personas.json")) -} - -fn built_in_persona_records(now: &str) -> Vec { +fn built_in_persona_records(now: &str) -> Vec { BUILT_IN_PERSONAS .iter() - .map(|persona| PersonaRecord { + .map(|persona| AgentDefinition { id: persona.id.to_string(), display_name: persona.display_name.to_string(), avatar_url: persona.avatar_url.map(|s| s.to_string()), @@ -244,6 +117,9 @@ fn built_in_persona_records(now: &str) -> Vec { source_team: None, source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, created_at: now.to_string(), updated_at: now.to_string(), }) @@ -256,7 +132,7 @@ fn built_in_order(id: &str) -> Option { .position(|persona| persona.id == id) } -fn sort_personas(records: &mut [PersonaRecord]) { +fn sort_personas(records: &mut [AgentDefinition]) { records.sort_by(|left, right| { let left_builtin = if left.is_builtin { 0 } else { 1 }; let right_builtin = if right.is_builtin { 0 } else { 1 }; @@ -278,32 +154,13 @@ fn sort_personas(records: &mut [PersonaRecord]) { }); } -fn merge_personas(mut stored: Vec, now: &str) -> (Vec, bool) { +fn merge_personas(mut stored: Vec, now: &str) -> (Vec, bool) { let mut changed = false; for built_in in built_in_persona_records(now) { if let Some(existing) = stored.iter_mut().find(|record| record.id == built_in.id) { - let created_at = existing.created_at.clone(); - let updated_at = existing.updated_at.clone(); - let is_active = existing.is_active; - // Built-in fields are canonical — user overrides on runtime/model are - // intentionally not preserved across restarts. Users who want a custom - // model or runtime should clone the built-in as a custom persona. - if existing.display_name != built_in.display_name - || existing.avatar_url != built_in.avatar_url - || existing.system_prompt != built_in.system_prompt - || existing.name_pool != built_in.name_pool - || existing.env_vars != built_in.env_vars - || existing.runtime != built_in.runtime - || existing.model != built_in.model - || !existing.is_builtin - { - *existing = PersonaRecord { - created_at, - updated_at, - is_active, - ..built_in - }; + if !existing.is_builtin { + existing.is_builtin = true; changed = true; } } else { @@ -341,7 +198,7 @@ fn merge_personas(mut stored: Vec, now: &str) -> (Vec bool { +fn migrate_retired_personas(stored: &mut [AgentDefinition], now: &str) -> bool { let mut changed = false; for record in stored.iter_mut() { @@ -375,13 +232,13 @@ fn migrate_retired_personas(stored: &mut [PersonaRecord], now: &str) -> bool { } pub fn ensure_persona_is_active( - personas: &[PersonaRecord], + personas: &[AgentDefinition], persona_id: &str, ) -> Result<(), String> { let persona = personas .iter() .find(|candidate| candidate.id == persona_id) - .ok_or_else(|| format!("persona {persona_id} not found"))?; + .ok_or_else(|| format!("agent {persona_id} not found"))?; if !persona.is_active { return Err(format!( @@ -394,7 +251,7 @@ pub fn ensure_persona_is_active( } pub fn ensure_persona_ids_are_active( - personas: &[PersonaRecord], + personas: &[AgentDefinition], persona_ids: &[String], ) -> Result<(), String> { for persona_id in persona_ids { @@ -405,16 +262,16 @@ pub fn ensure_persona_ids_are_active( } pub fn validate_persona_deletion( - persona: &PersonaRecord, + persona: &AgentDefinition, referenced_by_team: bool, ) -> Result<(), String> { if persona.is_builtin { - return Err("Built-in personas cannot be deleted.".to_string()); + return Err("Built-in agents cannot be deleted.".to_string()); } if persona.source_team.is_some() { return Err(format!( - "{} belongs to a team. Delete the team to remove all team personas together.", + "{} belongs to a team. Delete the team to remove all team agents together.", persona.display_name )); } @@ -430,15 +287,13 @@ pub fn validate_persona_deletion( } pub fn validate_persona_activation_change( - persona: &PersonaRecord, + persona: &AgentDefinition, active: bool, referenced_by_managed_agent: bool, referenced_by_team: bool, ) -> Result<(), String> { if !persona.is_builtin { - return Err( - "Only built-in personas can be added to or removed from My Agents.".to_string(), - ); + return Err("Only built-in agents can be added to or removed from My Agents.".to_string()); } if !active && referenced_by_managed_agent { @@ -458,35 +313,55 @@ pub fn validate_persona_activation_change( Ok(()) } -pub fn load_personas(app: &AppHandle) -> Result, String> { - let path = personas_store_path(app)?; +pub fn load_personas(app: &AppHandle) -> Result, String> { let now = now_iso(); - let records = if path.exists() { - let content = fs::read_to_string(&path) - .map_err(|error| format!("failed to read persona store: {error}"))?; - serde_json::from_str::>(&content) - .map_err(|error| format!("failed to parse persona store: {error}"))? - } else { - Vec::new() - }; + // Post-fold: definitions live in the unified agent store, presented in + // the legacy shape. Pre-fold stores are converted by + // `fold_personas_into_agent_store` in boot migrations before any caller + // reaches this shim. + let records = crate::managed_agents::storage::load_agent_definitions(app)? + .iter() + .filter_map(|record| record.to_definition_view()) + .collect(); let (records, changed) = merge_personas(records, &now); - if changed || !path.exists() { + if changed { save_personas(app, &records)?; } Ok(records) } -pub fn save_personas(app: &AppHandle, records: &[PersonaRecord]) -> Result<(), String> { +/// Read the raw persona records at `path` — no built-in merge, no write-back. +/// The single disk-read seam for persona definitions: `load_personas` layers +/// the built-in merge on top, and the boot-time readers that need raw records +/// without an `AppHandle` (`event_sync`, `migration::load_persona_runtimes`) +/// call it directly. The A2 store fold retargets THIS function at the unified +/// store; its callers stay unchanged. +pub(crate) fn load_personas_from_path( + path: &std::path::Path, +) -> Result, String> { + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(path) + .map_err(|error| format!("failed to read persona store: {error}"))?; + serde_json::from_str::>(&content) + .map_err(|error| format!("failed to parse persona store: {error}")) +} + +pub fn save_personas(app: &AppHandle, records: &[AgentDefinition]) -> Result<(), String> { let mut sorted = records.to_vec(); sort_personas(&mut sorted); - let path = personas_store_path(app)?; - let payload = serde_json::to_vec_pretty(&sorted) - .map_err(|error| format!("failed to serialize persona store: {error}"))?; - crate::managed_agents::storage::atomic_write_json(&path, &payload) + // Post-fold: persona saves write key-less definition records into the + // unified agent store (instances preserved by `save_agent_definitions`). + let definitions: Vec<_> = sorted + .into_iter() + .map(|persona| persona.into_agent_record()) + .collect(); + crate::managed_agents::storage::save_agent_definitions(app, &definitions) } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 1566f5f3d7..e924345e8b 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -1,13 +1,13 @@ use super::{ - ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, - migrate_retired_personas, validate_persona_activation_change, validate_persona_deletion, - BUILT_IN_PERSONAS, RETIRED_PERSONAS, + built_in_persona_records, ensure_persona_ids_are_active, ensure_persona_is_active, + merge_personas, migrate_retired_personas, validate_persona_activation_change, + validate_persona_deletion, BUILT_IN_PERSONAS, RETIRED_PERSONAS, }; -use crate::managed_agents::validate_team_id; -use crate::managed_agents::PersonaRecord; +use crate::managed_agents::discovery::{default_agent_command, effective_agent_command}; +use crate::managed_agents::AgentDefinition; -fn custom_persona(id: &str, display_name: &str) -> PersonaRecord { - PersonaRecord { +fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { + AgentDefinition { id: id.to_string(), display_name: display_name.to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), @@ -21,6 +21,9 @@ fn custom_persona(id: &str, display_name: &str) -> PersonaRecord { source_team: None, source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, created_at: "2026-03-19T00:00:00Z".to_string(), updated_at: "2026-03-19T00:00:00Z".to_string(), } @@ -35,32 +38,21 @@ fn merge_personas_adds_missing_built_ins() { assert!(records.iter().all(|record| record.is_builtin)); assert!(records .iter() - .any(|record| record.id == "builtin:fizz" && record.runtime.as_deref() == Some("goose"))); - assert!(records - .iter() - .any(|record| record.id == "builtin:product-strategist" && !record.is_active)); + .any(|record| record.id == "builtin:fizz" && record.runtime.is_none())); let display_names: Vec<&str> = records .iter() .map(|record| record.display_name.as_str()) .collect(); - assert_eq!( - display_names, - vec![ - "Fizz", - "Product Strategist", - "Implementation Partner", - "QA Reviewer", - "Work Coordinator", - "Support Guide", - "Experiment Designer" - ] - ); + assert_eq!(display_names, vec!["Fizz", "Honey", "Bumble"]); let active_ids: Vec<&str> = records .iter() .filter(|record| record.is_active) .map(|record| record.id.as_str()) .collect(); - assert_eq!(active_ids, vec!["builtin:fizz"]); + assert_eq!( + active_ids, + vec!["builtin:fizz", "builtin:honey", "builtin:bumble"] + ); } #[test] @@ -73,12 +65,33 @@ fn merge_personas_preserves_custom_records() { } #[test] -fn merge_personas_restores_builtin_defaults() { +fn merge_personas_preserves_builtin_edits() { let mut edited_builtin = custom_persona("builtin:fizz", "My Fizz"); edited_builtin.is_builtin = true; edited_builtin.is_active = true; - let original_created_at = edited_builtin.created_at.clone(); - let original_updated_at = edited_builtin.updated_at.clone(); + edited_builtin.system_prompt = "User-edited instructions".to_string(); + edited_builtin.name_pool = vec!["User-edited name".to_string()]; + edited_builtin.env_vars = + std::collections::BTreeMap::from([("USER_SETTING".to_string(), "value".to_string())]); + + let (records, changed) = merge_personas(vec![edited_builtin.clone()], "2026-03-19T00:00:00Z"); + + assert!(changed); // The remaining seeded built-ins are added. + let fizz = records + .iter() + .find(|record| record.id == "builtin:fizz") + .expect("fizz built-in should exist"); + assert_eq!(fizz.display_name, edited_builtin.display_name); + assert_eq!(fizz.system_prompt, edited_builtin.system_prompt); + assert_eq!(fizz.name_pool, edited_builtin.name_pool); + assert_eq!(fizz.env_vars, edited_builtin.env_vars); + assert_eq!(fizz.is_active, edited_builtin.is_active); +} + +#[test] +fn merge_personas_restores_builtin_marker_without_resetting_edits() { + let mut edited_builtin = custom_persona("builtin:fizz", "My Fizz"); + edited_builtin.is_builtin = false; let (records, changed) = merge_personas(vec![edited_builtin], "2026-03-19T00:00:00Z"); @@ -87,71 +100,8 @@ fn merge_personas_restores_builtin_defaults() { .iter() .find(|record| record.id == "builtin:fizz") .expect("fizz built-in should exist"); - let canonical = BUILT_IN_PERSONAS - .iter() - .find(|persona| persona.id == "builtin:fizz") - .expect("fizz built-in definition should exist"); - assert_eq!(fizz.display_name, canonical.display_name); - assert_eq!(fizz.avatar_url.as_deref(), canonical.avatar_url,); - assert_eq!(fizz.created_at, original_created_at); - assert_eq!(fizz.updated_at, original_updated_at); - assert!(fizz.is_active); -} - -#[test] -fn merge_personas_restores_builtin_env_vars() { - // A hand-edited built-in record with stray env vars should be reset to - // the canonical (empty) env on merge. Built-ins are intended immutable — - // if a user wants per-persona credentials, they create or duplicate to a - // custom persona. - let mut tampered = custom_persona("builtin:fizz", "Fizz"); - tampered.is_builtin = true; - tampered.avatar_url = None; - tampered.is_active = true; - tampered.env_vars = - std::collections::BTreeMap::from([("ANTHROPIC_API_KEY".to_string(), "leaked".to_string())]); - - let (records, changed) = merge_personas(vec![tampered], "2026-03-19T00:00:00Z"); - - assert!(changed); - let fizz = records - .iter() - .find(|record| record.id == "builtin:fizz") - .expect("fizz built-in should exist"); - // Built-in persona definitions have no `env_vars` field — they are - // always empty. The merge reset should clear the tampered key entirely. - assert!( - fizz.env_vars.is_empty(), - "expected empty, got {:?}", - fizz.env_vars - ); -} - -#[test] -fn merge_personas_restores_builtin_name_pool_and_preserves_is_active() { - let mut fizz = custom_persona("builtin:fizz", "Fizz"); - fizz.is_builtin = true; - fizz.avatar_url = None; - fizz.is_active = true; - fizz.name_pool = vec!["Definitely Not Fizz".to_string()]; - - let (records, changed) = merge_personas(vec![fizz], "2026-03-19T00:00:00Z"); - - assert!(changed); - let fizz = records - .iter() - .find(|record| record.id == "builtin:fizz") - .expect("fizz built-in should exist"); - let expected_name_pool = BUILT_IN_PERSONAS - .iter() - .find(|persona| persona.id == "builtin:fizz") - .expect("fizz built-in definition should exist") - .name_pool - .iter() - .map(|name| (*name).to_string()) - .collect::>(); - assert_eq!(fizz.name_pool, expected_name_pool); - assert!(fizz.is_active); + assert!(fizz.is_builtin); + assert_eq!(fizz.display_name, "My Fizz"); } #[test] @@ -210,7 +160,7 @@ fn merge_personas_demotes_retired_builtins() { fn ensure_persona_is_active_rejects_missing_personas() { let err = ensure_persona_is_active(&[], "missing").unwrap_err(); - assert_eq!(err, "persona missing not found"); + assert_eq!(err, "agent missing not found"); } #[test] @@ -249,7 +199,7 @@ fn validate_persona_activation_change_rejects_non_builtins() { assert_eq!( err, - "Only built-in personas can be added to or removed from My Agents." + "Only built-in agents can be added to or removed from My Agents." ); } @@ -295,7 +245,7 @@ fn validate_persona_deletion_rejects_builtins() { let err = validate_persona_deletion(&persona, false).unwrap_err(); - assert_eq!(err, "Built-in personas cannot be deleted."); + assert_eq!(err, "Built-in agents cannot be deleted."); } #[test] @@ -317,61 +267,6 @@ fn validate_persona_deletion_allows_safe_custom_personas() { assert!(validate_persona_deletion(&persona, false).is_ok()); } -// ── validate_team_id ────────────────────────────────────────────────────────── - -#[test] -fn pack_id_valid_reverse_dns() { - assert!(validate_team_id("com.example.security-team").is_ok()); -} - -#[test] -fn pack_id_valid_simple() { - assert!(validate_team_id("my-pack").is_ok()); -} - -#[test] -fn pack_id_rejects_empty() { - assert!(validate_team_id("").is_err()); -} - -#[test] -fn pack_id_rejects_dot_dot_path_traversal() { - // Critical regression test: ".." must never pass validation. - // A pack with id ".." would write into the parent directory. - assert!(validate_team_id("..").is_err()); -} - -#[test] -fn pack_id_rejects_single_dot() { - assert!(validate_team_id(".").is_err()); -} - -#[test] -fn pack_id_rejects_leading_dot() { - assert!(validate_team_id(".hidden").is_err()); -} - -#[test] -fn pack_id_rejects_slashes() { - assert!(validate_team_id("../etc/passwd").is_err()); - assert!(validate_team_id("foo/bar").is_err()); -} - -#[test] -fn pack_id_rejects_no_alphanumeric() { - assert!(validate_team_id("---").is_err()); - assert!(validate_team_id("___").is_err()); -} - -#[test] -fn pack_id_rejects_too_long() { - let long_id = "a".repeat(129); - assert!(validate_team_id(&long_id).is_err()); - // 128 chars is fine - let max_id = "a".repeat(128); - assert!(validate_team_id(&max_id).is_ok()); -} - // ── migrate_retired_personas ────────────────────────────────────────────────── #[test] @@ -379,9 +274,9 @@ fn migrate_retires_unmodified_personas() { let now = "2026-04-01T00:00:00Z"; // Simulate a store from before the Fizz transition: all 6 // retired personas with original system prompts. - let mut stored: Vec = RETIRED_PERSONAS + let mut stored: Vec = RETIRED_PERSONAS .iter() - .map(|(id, prompt)| PersonaRecord { + .map(|(id, prompt)| AgentDefinition { id: id.to_string(), system_prompt: prompt.to_string(), is_builtin: false, // already demoted by merge_personas @@ -416,7 +311,7 @@ fn migrate_retires_unmodified_personas() { #[test] fn migrate_preserves_customized_personas() { let now = "2026-04-01T00:00:00Z"; - let mut stored = vec![PersonaRecord { + let mut stored = vec![AgentDefinition { id: "builtin:researcher".to_string(), display_name: "My Researcher".to_string(), system_prompt: "My custom research workflow with special instructions".to_string(), @@ -449,7 +344,7 @@ fn migrate_is_idempotent() { assert_eq!(stored.len(), 1); // 2. Already-retired persona (display_name ends with " (retired)") — no-op. - let mut stored_with_retired = vec![PersonaRecord { + let mut stored_with_retired = vec![AgentDefinition { id: "builtin:researcher".to_string(), display_name: "Researcher (retired)".to_string(), system_prompt: "My custom prompt".to_string(), @@ -464,7 +359,7 @@ fn migrate_is_idempotent() { // 3. Retired persona still marked is_builtin: true (pre-demotion). // migrate_retired_personas should still soft-deprecate it. - let mut stored_pre_demotion = vec![PersonaRecord { + let mut stored_pre_demotion = vec![AgentDefinition { id: "builtin:reviewer".to_string(), display_name: "Reviewer".to_string(), system_prompt: "Custom review prompt".to_string(), @@ -479,3 +374,37 @@ fn migrate_is_idempotent() { // 4. Run again on result of (3) — should be no-op. assert!(!migrate_retired_personas(&mut stored_pre_demotion, now)); } + +// ── Fizz default harness ────────────────────────────────────────────────────── + +#[test] +fn fizz_builtin_has_no_pinned_runtime() { + // The Fizz built-in must not hard-pin a runtime so it inherits the + // bundled default (buzz-agent) rather than requiring goose on PATH. + let records = built_in_persona_records("2026-01-01T00:00:00Z"); + let fizz = records + .iter() + .find(|r| r.id == "builtin:fizz") + .expect("builtin:fizz must exist"); + assert_eq!( + fizz.runtime, None, + "Fizz built-in must not pin a runtime — it should inherit the default" + ); +} + +#[test] +fn fizz_builtin_resolves_to_buzz_agent() { + // With no runtime pin, effective_agent_command must fall through to + // default_agent_command(), which resolves the bundled buzz-agent. + let records = built_in_persona_records("2026-01-01T00:00:00Z"); + assert_eq!( + effective_agent_command(Some("builtin:fizz"), &records, None), + default_agent_command(), + "Fizz must resolve to the bundled default harness, not goose" + ); + assert_eq!( + effective_agent_command(Some("builtin:fizz"), &records, None), + "buzz-agent", + "Fizz must resolve to buzz-agent specifically" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index b3972d74e6..1ef3eae010 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -133,6 +133,9 @@ pub fn taskkill_tree(pid: u32) -> Result<(), String> { pub fn finish_spawn( child: std::process::Child, log_path: std::path::PathBuf, + spawn_config_hash: u64, + setup_mode: bool, + adapter_availability: Option, agent_name: &str, ) -> super::ManagedAgentProcess { let job = create_job_for_child(child.id()); @@ -145,6 +148,9 @@ pub fn finish_spawn( super::ManagedAgentProcess { child, log_path, + spawn_config_hash, + setup_mode, + adapter_availability, job, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs new file mode 100644 index 0000000000..95f20d1df5 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -0,0 +1,1672 @@ +//! Agent readiness evaluation. +//! +//! # Overview +//! +//! Before spawning a managed agent (or before deciding whether to enter +//! setup-mode nudge), the desktop must know whether the agent has every +//! piece of configuration it will need to start successfully. This module +//! provides: +//! +//! * [`EffectiveAgentEnv`] — the resolved environment a spawn would actually +//! see: baked build defaults (floor) → runtime metadata env vars → merged +//! user env_vars (last-wins) → reserved-key filtered. A separate +//! `config_file` tier tracks fields the harness reads from its config file +//! rather than the process env. +//! * [`resolve_effective_agent_env`] — assembles an `EffectiveAgentEnv` from +//! a record + personas + runtime catalog; no `AppHandle` dependency so it +//! is fully unit-testable. +//! * [`Requirement`] / [`RequirementSurface`] — structured predicates that +//! carry enough surface-discrimination for the UI to route each gap to the +//! right affordance (dropdown field vs env-var row vs CLI login step). +//! * [`AgentReadiness`] / [`agent_readiness`] — evaluates the effective env +//! against the requirements for the resolved runtime and returns `Ready` or +//! `NotReady(Vec)`. +//! +//! ## Env-assembly precedence (mirrors `spawn_agent_child`) +//! +//! 1. Baked build defaults (`baked_build_env()`) — injected first so the +//! layers above can override them. +//! 2. Runtime metadata env vars (`runtime_metadata_env_vars`) — provider / +//! model env keys derived from the record's `model`/`provider` fields and +//! the runtime's `model_env_var`/`provider_env_var`. +//! 3. Merged user env (`merged_user_env`) — live persona env under the +//! record's `env_vars` overrides, after reserved-key and malformed-key +//! filtering. Last-wins on collision. +//! +//! The config-file tier (Goose `~/.config/goose/config.yaml`) is tracked +//! separately because it is not part of the process env — the harness reads +//! it at startup. We do not evaluate it here; it is exposed for future +//! UI display only. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::managed_agents::{ + agent_env::baked_build_env, + config_bridge::read_goose_file_config, + discovery::{known_acp_runtime, KnownAcpRuntime}, + env_vars::merged_user_env, + global_config::GlobalAgentConfig, + types::{AcpAvailabilityStatus, AgentDefinition, ManagedAgentRecord}, +}; + +mod cli_login; +pub(crate) mod cli_probe; + +// ── EffectiveAgentEnv ───────────────────────────────────────────────────────── + +/// The resolved environment that a spawn of `record` would actually receive. +/// +/// Assembled from: baked build defaults (floor) → runtime metadata env vars +/// → merged user env_vars (last-wins) → reserved-key filtered. +/// +/// `config_file_path` is the harness config file path (if any) — not part of +/// the process env but relevant for display and future write-back dispatch. +/// `effective_command` is the resolved harness binary name (e.g. `"buzz-agent"`, +/// `"goose"`) after persona and override resolution. +#[derive(Debug, Clone)] +pub(crate) struct EffectiveAgentEnv { + /// The process-env map the spawned harness would receive. + pub env: BTreeMap, + /// Harness config file path, if any (e.g. `~/.config/goose/config.yaml`). + // Not read yet; kept for the unified-agent-record rewrite (chunk A) which + // replaces this resolution path wholesale. + #[allow(dead_code)] + pub config_file_path: Option<&'static str>, + /// The resolved harness binary name (e.g. `"buzz-agent"`, `"goose"`). + pub effective_command: String, +} + +/// Assemble the effective agent env from a record, personas, optional +/// known-runtime metadata, and the global agent config defaults — without an +/// `AppHandle` so it is fully unit-testable. +/// +/// # Arguments +/// * `record` — the managed agent record (model/provider/env_vars/…) +/// * `personas` — all current persona records (for persona-backed resolution) +/// * `runtime` — the `KnownAcpRuntime` for the effective command, if any +/// * `global` — global agent config defaults (lowest user layer; pass +/// `&GlobalAgentConfig::default()` in tests that don't need global config) +pub(crate) fn resolve_effective_agent_env( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + runtime: Option<&KnownAcpRuntime>, + global: &GlobalAgentConfig, +) -> EffectiveAgentEnv { + let effective_command = crate::managed_agents::record_agent_command(record, personas); + + // Layer 1: baked build defaults (floor — internal builds only; OSS = empty). + let mut env = baked_build_env(); + + // Layer 2: runtime metadata env vars (model / provider keys derived from + // the record's structured fields, with global as fallback). + // + // Uses the shared resolver to guarantee readiness and spawn agree on the + // effective model/provider: agent → persona → global → None. + let (effective_model, effective_provider) = + super::global_config::resolve_effective_model_provider(record, personas, global); + + if let Some(rt) = runtime { + for (key, value) in super::runtime::runtime_metadata_env_vars( + rt.model_env_var, + rt.provider_env_var, + rt.provider_locked, + effective_model, + effective_provider, + ) { + env.insert(key.to_string(), value.to_string()); + } + } + + // Layer 3a: global env vars — the lowest user-settable layer. + // Injected before persona/agent so per-agent values win on collision. + // `merged_user_env` with an empty "lower" map applies reserved/malformed-key + // filtering to the global map for free. + let global_env = merged_user_env(&BTreeMap::new(), &global.env_vars); + env.extend(global_env); + + // Layer 3b: merged user env — live persona env under the record's own + // overrides (last-wins), after reserved/malformed-key filtering. Reading + // the persona live is what makes persona credential edits refresh on the + // next spawn instead of being frozen into the record. + let user_env = merged_user_env( + &super::env_vars::live_persona_env(personas, record.persona_id.as_deref()), + &record.env_vars, + ); + env.extend(user_env); + + // Buzz shared compute is a native Buzz provider. Translate it to buzz-agent's + // OpenAI-compatible transport only in the effective runtime environment. + #[cfg(feature = "mesh-llm")] + super::apply_relay_mesh_env(&mut env, effective_provider, effective_model); + + EffectiveAgentEnv { + env, + config_file_path: runtime.and_then(|r| r.config_file_path), + effective_command, + } +} + +// ── Requirement types ───────────────────────────────────────────────────────── + +/// A single missing piece of configuration, tagged with the UI surface that +/// owns it so the UI can route each gap to the right affordance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "surface", rename_all = "snake_case")] +pub enum Requirement { + /// A normalized dropdown field (provider or model) that is missing. + /// Routes to the provider/model dropdown in the Edit Agent dialog. + NormalizedField { + /// Camel-case field name matching `NormalizedConfig` ("provider", "model"). + field: String, + }, + /// An env-backed credential that is absent from the effective env. + /// Routes to the env-var row editor in the Edit Agent dialog. + EnvKey { + /// The env var key name (e.g. `"ANTHROPIC_API_KEY"`). + key: String, + }, + /// A CLI authentication step that must be completed interactively. + /// Routes to a setup instruction panel in the Edit Agent dialog. + CliLogin { + /// Arguments for the login-status probe (e.g. `["claude", "auth", "status"]`). + probe_args: Vec, + /// Human-readable instruction for completing the login + /// (e.g. `"run \`codex login\`"`). + setup_copy: String, + /// Granular install/auth state for this runtime — distinguishes + /// "not installed" from "logged out" from "adapter missing". + /// Carried to the FE so the nudge card can show the right message + /// and route to Doctor with accurate context. + availability: AcpAvailabilityStatus, + }, + /// The CLI is installed but its config file could not be parsed. + /// This is an informational surface only — there is no in-app destination + /// that can repair an external config file; the user must edit it manually. + CliConfigInvalid { + /// Arguments used in the probe (e.g. `["codex", "login", "status"]`); + /// `probe_args[0]` is the CLI name (e.g. `"codex"`). + probe_args: Vec, + /// Human-readable hint shown when no structured copy is available. + setup_copy: String, + /// A one-line excerpt from the CLI's stderr (the parse-error line). + /// Shown verbatim in the nudge so the user can identify the problem. + diagnostic: String, + }, + /// Git for Windows is missing, so buzz-agent cannot launch buzz-dev-mcp's + /// Bash-based shell tool. Doctor owns installation and re-checking. + GitBash, +} + +// ── AgentReadiness ──────────────────────────────────────────────────────────── + +/// Whether a managed agent has all required configuration to start. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum AgentReadiness { + /// All required configuration is present — safe to spawn normally. + Ready, + /// One or more requirements are missing. + NotReady { + /// Surface-discriminated list of what is missing. + requirements: Vec, + }, +} + +impl AgentReadiness { + /// Returns `true` if the agent is ready to spawn. + #[cfg(test)] + pub(crate) fn is_ready(&self) -> bool { + matches!(self, AgentReadiness::Ready) + } + + /// Returns the missing requirements, or an empty slice if ready. + #[cfg(test)] + pub(crate) fn requirements(&self) -> &[Requirement] { + match self { + AgentReadiness::Ready => &[], + AgentReadiness::NotReady { requirements } => requirements, + } + } +} + +// ── agent_readiness ─────────────────────────────────────────────────────────── + +/// Evaluate whether a managed agent has all required configuration to start. +/// +/// Checks the `effective` env surface against the requirements for the +/// resolved runtime: +/// +/// * **buzz-agent / goose**: provider + model are required (both must be +/// present in the effective env or as structured fields). Additionally, +/// provider-specific credentials are required: +/// - `anthropic` → `ANTHROPIC_API_KEY` +/// - `openai` → `OPENAI_COMPAT_API_KEY` +/// - `databricks` / `databricks_v2` → `DATABRICKS_HOST` (token optional — +/// OAuth PKCE is the fallback) +/// * **claude**: a successful `claude auth status` probe. +/// * **codex**: a successful `codex login status` probe (checks the codex +/// credential store — NOT `OPENAI_API_KEY`). +/// * **unknown / custom command**: always `Ready` (no requirements known). +/// +/// Databricks note: `DATABRICKS_TOKEN` is `.unwrap_or_default()` in +/// `buzz-agent/src/config.rs:143` — it is an escape hatch for static tokens +/// but the normal path is OAuth PKCE. We intentionally do NOT mark the +/// token as required to avoid a false NotReady for users on OAuth. +pub(crate) fn agent_readiness(effective: &EffectiveAgentEnv) -> AgentReadiness { + let runtime = known_acp_runtime(&effective.effective_command); + let missing = collect_missing_requirements(effective, runtime); + if missing.is_empty() { + AgentReadiness::Ready + } else { + AgentReadiness::NotReady { + requirements: missing, + } + } +} + +/// Collect all missing requirements for the given effective env + runtime. +fn collect_missing_requirements( + effective: &EffectiveAgentEnv, + runtime: Option<&KnownAcpRuntime>, +) -> Vec { + let Some(rt) = runtime else { + // Unknown/custom command — no requirements to check. + return vec![]; + }; + + match rt.id { + "buzz-agent" => buzz_agent_requirements(effective), + "goose" => { + // Read the file config once at the call site so the inner fn is + // pure and unit-testable by injection. + let file_cfg = read_goose_file_config(); + goose_requirements(effective, file_cfg.as_ref()) + } + "claude" => cli_login::requirements( + &["claude", "auth", "status"], + "complete Claude Code authentication by running the Claude CLI", + rt, + ), + "codex" => cli_login::requirements(&["codex", "login", "status"], "run `codex login`", rt), + _ => vec![], + } +} + +/// Requirements for buzz-agent (provider + model + provider-specific creds). +fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { + let mut missing = Vec::new(); + + #[cfg(windows)] + if !crate::managed_agents::git_bash_available(&effective.env) { + missing.push(Requirement::GitBash); + } + + // Provider is required — maps to BUZZ_AGENT_PROVIDER in the effective env. + // An empty string is treated as absent: a key set to "" is not a valid + // provider and must not pass the readiness gate. + let provider = effective + .env + .get("BUZZ_AGENT_PROVIDER") + .filter(|v| !v.is_empty()) + .map(String::as_str); + if provider.is_none() { + missing.push(Requirement::NormalizedField { + field: "provider".to_string(), + }); + } + + // Model is required — maps to BUZZ_AGENT_MODEL in the effective env. + // Same empty-string treatment as provider. + // Also accept provider-specific model fallback keys, matching buzz-agent's + // own config.rs `from_env()` resolution order (e.g. DATABRICKS_MODEL for + // databricks/databricks_v2, ANTHROPIC_MODEL for anthropic, etc.). The + // baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL, + // so without this fallback agents baked from releases appear "not ready". + let provider_model_key = match provider { + Some("databricks") | Some("databricks_v2") | Some("databricks-v2") => { + Some("DATABRICKS_MODEL") + } + Some("anthropic") => Some("ANTHROPIC_MODEL"), + Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"), + _ => None, + }; + let model_present = effective + .env + .get("BUZZ_AGENT_MODEL") + .filter(|v| !v.is_empty()) + .is_some() + || provider_model_key + .and_then(|k| effective.env.get(k)) + .filter(|v| !v.is_empty()) + .is_some(); + if !model_present { + missing.push(Requirement::NormalizedField { + field: "model".to_string(), + }); + } + + // Provider-specific credential requirements. + // A key present with an empty value is treated as absent — matching the + // dialog's (envVars[key] ?? "").length === 0 emptiness check. + let env_key_missing = |key: &str| effective.env.get(key).is_none_or(|v| v.is_empty()); + match provider { + Some("anthropic") + if env_key_missing("ANTHROPIC_API_KEY") => { + missing.push(Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }); + } + Some("openai") + if env_key_missing("OPENAI_COMPAT_API_KEY") => { + missing.push(Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string(), + }); + } + Some("databricks") | Some("databricks_v2") | Some("databricks-v2") + // DATABRICKS_HOST is hard-required; DATABRICKS_TOKEN is optional + // (OAuth PKCE is the normal path — see buzz-agent/src/config.rs:143). + if env_key_missing("DATABRICKS_HOST") => { + missing.push(Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string(), + }); + } + _ => { + // Unknown provider or no provider yet — only the NormalizedField + // requirement above captures this gap. + } + } + + missing +} + +/// Requirements for goose (provider + model + provider-specific creds). +/// +/// Mirrors buzz-agent requirements but uses GOOSE_PROVIDER / GOOSE_MODEL. +/// +/// File-config tier: goose reads `~/.config/goose/config.yaml` at startup. +/// Requirements already satisfied there are silenced — we don't need to +/// require them from Buzz's env layer. The file layer only *silences* +/// requirements; it never injects values into the spawn env. +/// +/// `file_cfg` is injected by the caller (read once at `collect_missing_requirements`) +/// so this function is pure and unit-testable without touching disk. +fn goose_requirements( + effective: &EffectiveAgentEnv, + file_cfg: Option<&crate::managed_agents::config_bridge::RuntimeFileConfig>, +) -> Vec { + let mut missing = Vec::new(); + + // Empty string treated as absent — same as buzz_agent_requirements. + let provider = effective + .env + .get("GOOSE_PROVIDER") + .filter(|v| !v.is_empty()) + .map(String::as_str); + + // Effective provider for credential checking: prefer env layer, then file. + let effective_provider = provider.or_else(|| { + file_cfg + .as_ref() + .and_then(|c| c.provider.as_deref()) + .filter(|v| !v.is_empty()) + }); + + if provider.is_none() { + // Silenced if the file config provides a provider. + let file_provides_provider = file_cfg + .as_ref() + .and_then(|c| c.provider.as_deref()) + .filter(|v| !v.is_empty()) + .is_some(); + if !file_provides_provider { + missing.push(Requirement::NormalizedField { + field: "provider".to_string(), + }); + } + } + + let model = effective + .env + .get("GOOSE_MODEL") + .filter(|v| !v.is_empty()) + .map(String::as_str); + if model.is_none() { + // Silenced if the file config provides a model. + let file_provides_model = file_cfg + .as_ref() + .and_then(|c| c.model.as_deref()) + .filter(|v| !v.is_empty()) + .is_some(); + if !file_provides_model { + missing.push(Requirement::NormalizedField { + field: "model".to_string(), + }); + } + } + + // Provider-specific credentials — same empty-string semantics as buzz-agent. + let env_key_missing = |key: &str| effective.env.get(key).is_none_or(|v| v.is_empty()); + // A credential key is also satisfied when the file config's `extra` map + // contains it (e.g. DATABRICKS_HOST set in the goose config file). + let file_key_present = |key: &str| -> bool { + file_cfg + .as_ref() + .map(|c| c.extra.get(key).is_some_and(|v| !v.is_empty())) + .unwrap_or(false) + }; + match effective_provider { + Some("anthropic") + if env_key_missing("ANTHROPIC_API_KEY") && !file_key_present("ANTHROPIC_API_KEY") => + { + missing.push(Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }); + } + Some("openai") + if env_key_missing("OPENAI_COMPAT_API_KEY") + && !file_key_present("OPENAI_COMPAT_API_KEY") => + { + missing.push(Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string(), + }); + } + Some("databricks") | Some("databricks_v2") | Some("databricks-v2") + if env_key_missing("DATABRICKS_HOST") && !file_key_present("DATABRICKS_HOST") => + { + missing.push(Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string(), + }); + } + _ => {} + } + + missing +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::managed_agents::discovery::known_acp_runtime_exact; + + /// Build a minimal `EffectiveAgentEnv` with the given env map and command. + fn make_env(command: &str, env: BTreeMap) -> EffectiveAgentEnv { + let runtime = known_acp_runtime_exact(command); + EffectiveAgentEnv { + env, + config_file_path: runtime.and_then(|r| r.config_file_path), + effective_command: command.to_string(), + } + } + + fn env_with(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + // ── buzz-agent tests ────────────────────────────────────────────────── + + #[test] + fn buzz_agent_missing_provider_returns_not_ready_with_normalized_field() { + let env = make_env( + "buzz-agent", + env_with(&[("BUZZ_AGENT_MODEL", "claude-opus-4-5")]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "missing BUZZ_AGENT_PROVIDER should be NotReady" + ); + let reqs = result.requirements(); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements should include NormalizedField(provider); got {reqs:?}" + ); + } + + #[test] + fn buzz_agent_missing_model_returns_not_ready_with_normalized_field() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); + } + + #[test] + fn buzz_agent_missing_anthropic_key_returns_not_ready_with_env_key() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + })); + } + + #[test] + fn buzz_agent_missing_openai_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openai"), + ("BUZZ_AGENT_MODEL", "gpt-4o"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string() + })); + } + + #[test] + fn buzz_agent_anthropic_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); + } + + #[test] + fn buzz_agent_databricks_with_host_and_model_is_ready_without_token() { + // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. + // No token present, no OAuth cache present → still Ready because we + // cannot evaluate OAuth state from the env map alone. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + // NOTE: no DATABRICKS_TOKEN + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "Databricks with HOST+model but no TOKEN should still be Ready (OAuth path)" + ); + } + + #[test] + fn buzz_agent_databricks_missing_host_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + // NOTE: no DATABRICKS_HOST + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); + } + + #[test] + fn buzz_agent_databricks_v2_missing_host_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ( + "BUZZ_AGENT_MODEL", + "databricks/meta-llama-4-maverick-17b-instruct", + ), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); + } + + // ── goose tests ─────────────────────────────────────────────────────── + + #[test] + fn goose_missing_provider_returns_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env("goose", env_with(&[("GOOSE_MODEL", "claude-opus-4-5")])); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "missing GOOSE_PROVIDER with no file config must produce requirements" + ); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements must include NormalizedField(provider); got {reqs:?}" + ); + } + + #[test] + fn goose_with_provider_and_model_and_key_is_ready() { + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); + } + + // ── empty-string semantics ──────────────────────────────────────────── + // + // A key present with an empty value ("") must be treated as MISSING, to + // match the dialog's (envVars[key] ?? "").length === 0 emptiness check. + + #[test] + fn buzz_agent_empty_string_provider_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", ""), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string BUZZ_AGENT_PROVIDER must be treated as missing" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "provider".to_string() + })); + } + + #[test] + fn buzz_agent_empty_string_model_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", ""), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string BUZZ_AGENT_MODEL must be treated as missing" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); + } + + #[test] + fn buzz_agent_empty_string_anthropic_key_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", ""), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string ANTHROPIC_API_KEY must be treated as missing" + ); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + })); + } + + #[test] + fn buzz_agent_empty_string_databricks_host_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", ""), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string DATABRICKS_HOST must be treated as missing" + ); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); + } + + #[test] + fn goose_empty_string_provider_is_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env( + "goose", + env_with(&[("GOOSE_PROVIDER", ""), ("GOOSE_MODEL", "claude-opus-4-5")]), + ); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "empty-string GOOSE_PROVIDER must be treated as missing" + ); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements must include NormalizedField(provider); got {reqs:?}" + ); + } + + #[test] + fn goose_empty_string_anthropic_key_is_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", ""), + ]), + ); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "empty-string ANTHROPIC_API_KEY must be treated as missing (goose)" + ); + assert!( + reqs.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "requirements must include ANTHROPIC_API_KEY; got {reqs:?}" + ); + } + + // ── codex tests ─────────────────────────────────────────────────────── + + #[test] + fn codex_not_ready_copy_does_not_mention_openai_api_key() { + // codex uses its own credential store via `codex login` (OAuth or API key). + // The nudge copy must NOT say "set OPENAI_API_KEY". + // Use a not-installed runtime so the requirement is always emitted + // regardless of whether codex is on the test machine's PATH. + let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], None); + let reqs = cli_login::requirements(&["codex", "login", "status"], "run `codex login`", &rt); + // Whether codex is installed or not, the copy (if any) must not mention OPENAI_API_KEY. + for req in &reqs { + if let Requirement::CliLogin { setup_copy, .. } = req { + assert!( + !setup_copy.contains("OPENAI_API_KEY"), + "codex nudge copy must not mention OPENAI_API_KEY; got: {setup_copy:?}" + ); + assert!( + setup_copy.contains("codex login"), + "codex nudge copy should mention `codex login`; got: {setup_copy:?}" + ); + } + } + } + + // ── cli_login_requirements: resolve_command integration ───────────── + + /// Construct a minimal `KnownAcpRuntime` stub for testing cli_login_requirements. + /// `commands` are the adapter binaries; `underlying_cli` is the CLI name. + fn make_cli_runtime( + commands: &'static [&'static str], + underlying_cli: Option<&'static str>, + ) -> KnownAcpRuntime { + KnownAcpRuntime { + id: "test-cli-runtime", + label: "Test CLI", + commands, + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + config_file_path: None, + config_file_format: None, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: None, + auth_probe_args: None, + } + } + + /// Returns the absolute path of the currently-running test binary as a + /// `&'static str`. Host-portable stand-in for a "present" binary: + /// the path is absolute so `find_command` resolves it via `path.exists()` + /// rather than searching `PATH`, and the file always exists on the host. + /// + /// The tiny allocation is intentionally leaked — this runs at most once per + /// test process and the process exits immediately after tests complete. + fn present_binary_str() -> &'static str { + let path = std::env::current_exe().expect("current_exe must be available in tests"); + Box::leak(path.to_string_lossy().into_owned().into_boxed_str()) + } + + /// Leak a runtime slice of `'static` strs for use in `make_cli_runtime`. + fn static_commands(commands: Vec<&'static str>) -> &'static [&'static str] { + Box::leak(commands.into_boxed_slice()) + } + + #[test] + fn cli_login_requirements_missing_binary_is_not_ready() { + // Both adapter and underlying CLI are nonexistent → NotInstalled state + // → must return a CliLogin requirement with availability=NotInstalled. + let rt = make_cli_runtime( + &["__buzz_nonexistent_adapter_abc123__"], + Some("__buzz_nonexistent_cli_abc123__"), + ); + let reqs = cli_login::requirements( + &["__buzz_nonexistent_binary_abc123__", "status"], + "install the tool first", + &rt, + ); + assert!( + !reqs.is_empty(), + "missing binary must produce a CliLogin requirement (NotReady)" + ); + assert!( + matches!(reqs[0], Requirement::CliLogin { .. }), + "requirement must be CliLogin; got {:?}", + reqs[0] + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::NotInstalled, + "both missing → NotInstalled" + ); + } + } + + #[test] + fn cli_login_requirements_adapter_missing_emits_adapter_missing() { + // Underlying CLI present (use the running test binary as a portable + // stand-in — it's always present and resolves via absolute path), + // adapter absent. + // → AdapterMissing state → no probe run → CliLogin{AdapterMissing}. + let exe = present_binary_str(); + let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], Some(exe)); + let reqs = cli_login::requirements(&[exe, "--list"], "install the adapter", &rt); + assert!( + !reqs.is_empty(), + "adapter missing must produce a CliLogin requirement" + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::AdapterMissing, + "adapter absent, CLI present → AdapterMissing" + ); + } + } + + #[test] + fn cli_login_requirements_cli_missing_emits_cli_missing() { + // Adapter present (use the running test binary as a portable stand-in), + // underlying CLI absent. + // → CliMissing state → no probe run → CliLogin{CliMissing}. + let exe = present_binary_str(); + let rt = make_cli_runtime( + static_commands(vec![exe]), // adapter found via absolute path + Some("__buzz_nonexistent_cli_abc123__"), // underlying CLI missing + ); + let reqs = cli_login::requirements(&[exe, "--list"], "install the CLI", &rt); + assert!( + !reqs.is_empty(), + "CLI missing must produce a CliLogin requirement" + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::CliMissing, + "adapter present, CLI absent → CliMissing" + ); + } + } + + #[test] + fn cli_login_requirements_resolvable_binary_runs_probe_at_resolved_path() { + // Both adapter and CLI present (use the running test binary as a + // portable stand-in — always present, resolves via absolute path), + // probe exits 0 (run with `--list` which lists tests and exits 0). + // → logged_in = true → requirements is empty (Ready). + let exe = present_binary_str(); + let rt = make_cli_runtime(static_commands(vec![exe]), Some(exe)); + let reqs = cli_login::requirements( + &[exe, "--list"], + "this should not show (probe exits 0)", + &rt, + ); + assert!( + reqs.is_empty(), + "expected Ready (no requirements) when probe binary resolves and exits 0; \ + got {:?}", + reqs + ); + } + + #[test] + fn cli_login_requirements_logged_out_emits_available() { + // Both adapter and CLI present, but probe exits non-zero (logged out). + // Use the test binary with an unrecognized argument as the probe — + // libtest exits non-zero for unknown flags on all platforms. + // → CliLogin{Available} (tooling installed, needs login). + let exe = present_binary_str(); + let rt = make_cli_runtime(static_commands(vec![exe]), Some(exe)); + let reqs = + cli_login::requirements(&[exe, "--buzz-probe-fail-xyz"], "run `tool login`", &rt); + assert!( + !reqs.is_empty(), + "non-zero probe must produce a CliLogin requirement (logged out)" + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::Available, + "tooling installed, probe fails → Available (logged-out)" + ); + } + } + + // ── codex readiness version gate ─────────────────────────────────────── + + /// Build a minimal `KnownAcpRuntime` for testing the codex version gate. + /// `adapter_commands` are the exact strings passed to `find_command` — use + /// `&["codex-acp"]` when the binary is on PATH, or `&[]` + /// when resolving via absolute path. `underlying_cli` is a portable + /// stand-in so the adapter is not misclassified as `CliMissing`. + fn make_codex_runtime( + adapter_commands: &'static [&'static str], + underlying_cli: Option<&'static str>, + ) -> KnownAcpRuntime { + KnownAcpRuntime { + id: "codex", + label: "Codex", + commands: adapter_commands, + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + config_file_path: None, + config_file_format: None, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: None, + auth_probe_args: None, + } + } + + /// Build a temp dir containing a `codex-acp` script with the given body, + /// prepend it to PATH, and clear the resolve cache. Returns the temp dir + /// and the original PATH string for restoration. + #[cfg(unix)] + fn setup_temp_codex_acp(script_body: &str) -> (tempfile::TempDir, String) { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("create temp dir"); + let bin = dir.path().join("codex-acp"); + std::fs::write(&bin, script_body).expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod script"); + + let original_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", dir.path().display(), original_path); + std::env::set_var("PATH", &new_path); + crate::managed_agents::clear_resolve_cache(); + + (dir, original_path) + } + + /// Restore PATH and clear the resolve cache after a PATH-mutating test. + #[cfg(unix)] + fn restore_path(original: &str) { + std::env::set_var("PATH", original); + crate::managed_agents::clear_resolve_cache(); + } + + /// Codex readiness: outdated adapter (exits non-zero) → AdapterOutdated, + /// login probe skipped. + #[cfg(unix)] + #[test] + fn cli_login_requirements_codex_outdated_adapter_emits_adapter_outdated() { + let _guard = crate::managed_agents::lock_path_mutex(); + + let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\nexit 1\n"); + let exe = present_binary_str(); + // underlying_cli = running test binary (always present, never probed) + let rt = make_codex_runtime(&["codex-acp"], Some(exe)); + let reqs = cli_login::requirements( + &[exe, "--buzz-probe-must-not-run-xyz"], + "run `codex login`", + &rt, + ); + + restore_path(&orig); + drop(dir); + + assert!( + !reqs.is_empty(), + "outdated codex adapter must produce a requirement; got {reqs:?}" + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, + "0.x codex adapter must yield AdapterOutdated; got {availability:?}" + ); + } else { + panic!("expected CliLogin requirement; got {:?}", reqs[0]); + } + } + + /// Codex readiness: adapter exits 0 but output is not a parseable version + /// → AdapterOutdated (garbage output treated as outdated, same as non-zero). + #[cfg(unix)] + #[test] + fn cli_login_requirements_codex_garbage_version_output_emits_adapter_outdated() { + let _guard = crate::managed_agents::lock_path_mutex(); + + let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\necho 'not a version string'\nexit 0\n"); + let exe = present_binary_str(); + let rt = make_codex_runtime(&["codex-acp"], Some(exe)); + let reqs = cli_login::requirements( + &[exe, "--buzz-probe-must-not-run-xyz"], + "run `codex login`", + &rt, + ); + + restore_path(&orig); + drop(dir); + + assert!( + !reqs.is_empty(), + "garbage version output must produce a requirement; got {reqs:?}" + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, + "unparseable version output must yield AdapterOutdated; got {availability:?}" + ); + } else { + panic!("expected CliLogin requirement; got {:?}", reqs[0]); + } + } + + // ── custom/unknown command ───────────────────────────────────────────── + + #[test] + fn unknown_command_is_always_ready() { + let env = make_env("my-custom-harness", BTreeMap::new()); + assert!( + agent_readiness(&env).is_ready(), + "unknown/custom command should always be Ready (no requirements)" + ); + } + + // ── AgentReadiness helpers ───────────────────────────────────────────── + + #[test] + fn agent_readiness_ready_has_empty_requirements() { + assert!(AgentReadiness::Ready.requirements().is_empty()); + } + + #[test] + fn agent_readiness_not_ready_exposes_requirements() { + let r = AgentReadiness::NotReady { + requirements: vec![Requirement::EnvKey { + key: "FOO".to_string(), + }], + }; + assert!(!r.is_ready()); + assert_eq!(r.requirements().len(), 1); + } + + // ── Requirement serialization ───────────────────────────────────────── + + #[test] + fn requirement_serializes_with_surface_tag() { + let r = Requirement::NormalizedField { + field: "provider".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "normalized_field"); + assert_eq!(json["field"], "provider"); + } + + #[test] + fn git_bash_requirement_serializes_correctly() { + let json = serde_json::to_value(Requirement::GitBash).unwrap(); + assert_eq!(json, serde_json::json!({ "surface": "git_bash" })); + } + + #[test] + fn env_key_requirement_serializes_correctly() { + let r = Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "env_key"); + assert_eq!(json["key"], "ANTHROPIC_API_KEY"); + } + + #[test] + fn cli_login_requirement_serializes_correctly() { + let r = Requirement::CliLogin { + probe_args: vec![ + "codex".to_string(), + "login".to_string(), + "status".to_string(), + ], + setup_copy: "run `codex login`".to_string(), + availability: crate::managed_agents::AcpAvailabilityStatus::Available, + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "cli_login"); + assert!(json["probe_args"].is_array()); + assert!(json["setup_copy"].as_str().unwrap().contains("codex login")); + } + + // ── resolve_effective_agent_env ───────────────────────────────────────── + + #[test] + fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { + // A record whose env_vars explicitly set provider/model must win over + // any baked defaults. In OSS test builds the baked map is empty, so + // this test validates the user-env layer is present in the output. + let mut env_vars = BTreeMap::new(); + env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); + env_vars.insert( + "BUZZ_AGENT_MODEL".to_string(), + "claude-opus-4-5".to_string(), + ); + + // Minimal record: only the fields resolve_effective_agent_env reads. + let record = crate::managed_agents::types::ManagedAgentRecord { + pubkey: "test-pubkey".to_string(), + name: "test-agent".to_string(), + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "buzz-agent".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + }; + + let runtime = known_acp_runtime_exact("buzz-agent"); + let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default()); + + // User env_vars must be present in the output (last-write-wins). + assert_eq!( + effective.env.get("BUZZ_AGENT_PROVIDER").map(String::as_str), + Some("anthropic") + ); + assert_eq!( + effective.env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some("claude-opus-4-5") + ); + } + + // ── provider-specific model fallback tests ──────────────────────────── + + #[test] + fn buzz_agent_databricks_v2_with_databricks_model_but_no_buzz_agent_model_is_ready() { + // The baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL. + // An agent with only DATABRICKS_MODEL must pass the readiness gate. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "DATABRICKS_MODEL must satisfy the model requirement for databricks_v2" + ); + } + + #[test] + fn buzz_agent_databricks_v2_hyphen_alias_with_databricks_model_is_ready() { + // buzz-agent accepts both "databricks_v2" and "databricks-v2". The + // readiness gate must recognize the hyphen alias and accept DATABRICKS_MODEL. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks-v2"), + ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "databricks-v2 alias with DATABRICKS_MODEL must be Ready" + ); + } + + #[test] + fn buzz_agent_databricks_hyphen_alias_missing_host_returns_not_ready() { + // The hyphen alias "databricks-v2" requires DATABRICKS_HOST just like + // the underscore variants. Without it the agent cannot reach the endpoint. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks-v2"), + ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), + // DATABRICKS_HOST intentionally absent + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "databricks-v2 without DATABRICKS_HOST must be NotReady" + ); + let reqs = result.requirements(); + assert!( + reqs.iter() + .any(|r| matches!(r, Requirement::EnvKey { key } if key == "DATABRICKS_HOST")), + "missing requirements must include DATABRICKS_HOST; got {reqs:?}" + ); + } + + #[test] + fn buzz_agent_databricks_v1_with_databricks_model_but_no_buzz_agent_model_is_ready() { + // V1 (Model Serving) also resolves DATABRICKS_MODEL — same fallback applies. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("DATABRICKS_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "DATABRICKS_MODEL must satisfy the model requirement for databricks (V1)" + ); + } + + #[test] + fn buzz_agent_anthropic_with_anthropic_model_but_no_buzz_agent_model_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("ANTHROPIC_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "ANTHROPIC_MODEL must satisfy the model requirement for anthropic" + ); + } + + #[test] + fn buzz_agent_openai_with_openai_compat_model_but_no_buzz_agent_model_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openai"), + ("OPENAI_COMPAT_MODEL", "gpt-4o"), + ("OPENAI_COMPAT_API_KEY", "sk-test"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "OPENAI_COMPAT_MODEL must satisfy the model requirement for openai" + ); + } + + #[test] + fn buzz_agent_empty_provider_model_fallback_key_is_not_ready() { + // An empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must still be NotReady. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ("DATABRICKS_MODEL", ""), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must be NotReady" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); + } +} + +// ── goose file-config–aware requirement tests ───────────────────────────── +// +// These tests call `goose_requirements` directly, injecting a synthetic +// `RuntimeFileConfig` so there is no disk I/O and tests are deterministic. + +#[cfg(test)] +mod goose_file_config_tests { + use std::collections::BTreeMap; + + use super::*; + use crate::managed_agents::config_bridge::RuntimeFileConfig; + + fn empty_env() -> EffectiveAgentEnv { + EffectiveAgentEnv { + env: BTreeMap::new(), + config_file_path: Some("~/.config/goose/config.yaml"), + effective_command: "goose".to_string(), + } + } + + fn env_with(pairs: &[(&str, &str)]) -> EffectiveAgentEnv { + EffectiveAgentEnv { + env: pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + config_file_path: Some("~/.config/goose/config.yaml"), + effective_command: "goose".to_string(), + } + } + + fn databricks_file_config() -> RuntimeFileConfig { + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://dbc.example.com".to_string(), + ); + RuntimeFileConfig { + provider: Some("databricks_v2".to_string()), + model: Some("goose-claude-4-6-opus".to_string()), + extra, + ..Default::default() + } + } + + #[test] + fn goose_file_config_silences_databricks_host_requirement() { + // File has provider, model, and DATABRICKS_HOST — all requirements silenced. + let env = empty_env(); + let cfg = databricks_file_config(); + let result = goose_requirements(&env, Some(&cfg)); + assert!( + result.is_empty(), + "all requirements should be silenced by goose file config; \ + got: {:?}", + result + ); + } + + #[test] + fn goose_env_empty_file_absent_still_not_ready() { + // No env, no file config → provider and model both required. + let env = empty_env(); + let result = goose_requirements(&env, None); + assert!( + result.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "provider must be required when absent from both env and file" + ); + assert!( + result.contains(&Requirement::NormalizedField { + field: "model".to_string() + }), + "model must be required when absent from both env and file" + ); + } + + #[test] + fn goose_file_config_silences_provider_and_model_but_not_anthropic_key() { + // File has provider=anthropic and model, but ANTHROPIC_API_KEY is not + // in the file's `extra` map — it must still be required. + let cfg = RuntimeFileConfig { + provider: Some("anthropic".to_string()), + model: Some("claude-opus-4-5".to_string()), + extra: BTreeMap::new(), + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + // Provider and model silenced. + assert!( + !result.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "provider silenced by file config" + ); + assert!( + !result.contains(&Requirement::NormalizedField { + field: "model".to_string() + }), + "model silenced by file config" + ); + // ANTHROPIC_API_KEY not in file extra → still required. + assert!( + result.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "ANTHROPIC_API_KEY must remain required when not in file extra" + ); + } + + #[test] + fn goose_env_provider_wins_over_file_provider_for_cred_check() { + // Env has GOOSE_PROVIDER=anthropic (different from file's databricks_v2). + // The env provider must win for credential checking. + let env = env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ]); + let cfg = databricks_file_config(); // has provider=databricks_v2 + let result = goose_requirements(&env, Some(&cfg)); + // anthropic requires ANTHROPIC_API_KEY, not DATABRICKS_HOST. + assert!( + result.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "env provider=anthropic must require ANTHROPIC_API_KEY" + ); + assert!( + !result.contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + }), + "env provider=anthropic must NOT require DATABRICKS_HOST" + ); + } + + #[test] + fn goose_flat_databricks_host_in_file_config_silences_requirement() { + // Will's typical goose config: flat DATABRICKS_HOST at the top level, + // no active_provider — provider inferred as "databricks". + // The parser must store extra["DATABRICKS_HOST"] = value (canonical key), + // and goose_requirements must then silence the DATABRICKS_HOST requirement. + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://block.cloud.databricks.com".to_string(), + ); + let cfg = RuntimeFileConfig { + provider: Some("databricks".to_string()), + model: Some("goose-claude-4-5".to_string()), + extra, + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + // All requirements silenced — provider (file), model (file), DATABRICKS_HOST (file). + assert!( + result.is_empty(), + "flat DATABRICKS_HOST in file config must silence all requirements; \ + got: {:?}", + result + ); + } + + #[test] + fn goose_goose_provider_databricks_flat_host_silences_databricks_host() { + // GOOSE_PROVIDER=databricks (not active_provider) + flat DATABRICKS_HOST. + // The parser canonicalizes to extra["DATABRICKS_HOST"]; readiness must silence it. + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://dbc.example.com".to_string(), + ); + let cfg = RuntimeFileConfig { + provider: Some("databricks".to_string()), + model: Some("some-model".to_string()), + extra, + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + assert!( + !result.contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + }), + "DATABRICKS_HOST must be silenced when canonical key is in file extra" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs new file mode 100644 index 0000000000..47b1740cf7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -0,0 +1,82 @@ +use std::path::Path; + +use crate::managed_agents::{ + discovery::{ + classify_runtime, codex_adapter_availability, find_command, resolve_command, + KnownAcpRuntime, + }, + AcpAvailabilityStatus, +}; + +use super::{cli_probe, Requirement}; + +/// Requirements for CLI-login runtimes (claude, codex). +pub(super) fn requirements( + probe_args: &[&str], + setup_copy: &str, + runtime: &KnownAcpRuntime, +) -> Vec { + let adapter_result = runtime + .commands + .iter() + .find_map(|cmd| find_command(cmd).map(|path| (*cmd, path))); + let underlying_cli_found = runtime + .underlying_cli + .map(|cli| find_command(cli).is_some()) + .unwrap_or(false); + + let (availability, cmd, adapter_path) = + classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); + let availability = if runtime.id == "codex" + && availability == AcpAvailabilityStatus::Available + && cmd.as_deref() == Some("codex-acp") + { + adapter_path + .as_deref() + .map(|path| codex_adapter_availability(Path::new(path))) + .unwrap_or(availability) + } else { + availability + }; + + match availability { + AcpAvailabilityStatus::Available => { + let Some(binary_path) = resolve_command(probe_args[0]) else { + return vec![missing_requirement( + probe_args, + setup_copy, + AcpAvailabilityStatus::Available, + )]; + }; + let augmented_path = cli_probe::augmented_path(); + match cli_probe::login_probe(&binary_path, probe_args, augmented_path.as_deref()) { + cli_probe::ProbeOutcome::LoggedIn => vec![], + cli_probe::ProbeOutcome::LoggedOut => vec![missing_requirement( + probe_args, + setup_copy, + AcpAvailabilityStatus::Available, + )], + cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => { + vec![Requirement::CliConfigInvalid { + probe_args: probe_args.iter().map(|value| value.to_string()).collect(), + setup_copy: setup_copy.to_string(), + diagnostic: stderr_excerpt, + }] + } + } + } + other => vec![missing_requirement(probe_args, setup_copy, other)], + } +} + +fn missing_requirement( + probe_args: &[&str], + setup_copy: &str, + availability: AcpAvailabilityStatus, +) -> Requirement { + Requirement::CliLogin { + probe_args: probe_args.iter().map(|value| value.to_string()).collect(), + setup_copy: setup_copy.to_string(), + availability, + } +} diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs new file mode 100644 index 0000000000..8e2f866b7e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -0,0 +1,245 @@ +use std::path::Path; + +use crate::managed_agents::runtime::build_augmented_path; + +/// Build the augmented PATH for CLI probes, including nvm's default Node.js +/// bin directory so `#!/usr/bin/env node` shims (e.g. codex-acp) resolve. +pub(crate) fn augmented_path() -> Option { + let home = dirs::home_dir(); + let nvm_bin = home + .as_deref() + .and_then(crate::managed_agents::find_nvm_default_bin); + build_augmented_path( + home, + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)), + crate::managed_agents::login_shell_path(), + nvm_bin, + ) +} + +/// Outcome of a CLI login-status probe. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ProbeOutcome { + /// The CLI reported a successful login (exit 0). + LoggedIn, + /// The CLI exited non-zero without a config-parse signal — treat as + /// "not authenticated." + LoggedOut, + /// The CLI exited non-zero and its stderr contains a config-parse error + /// (e.g. from `~/.codex/config.toml`). The user needs to fix their + /// config, not re-run login. + ConfigInvalid { + /// A trimmed excerpt of the stderr message to surface in the nudge. + stderr_excerpt: String, + }, +} + +/// Signals emitted to stderr by codex (and related CLI tools) when they +/// fail to parse their config file. We check these to distinguish a +/// config-parse failure from a genuine "not authenticated" exit. +/// +/// The real codex error reads: +/// `Error loading configuration: .../.codex/config.toml:... unknown variant ...` +/// So we require BOTH "error loading configuration" AND "unknown variant" to be +/// present, avoiding false positives from unrelated errors that mention only +/// one term. +const CONFIG_PARSE_SIGNALS: &[&str] = &["error loading configuration", "unknown variant"]; + +/// Run the probe at the resolved absolute path so the GUI-PATH gap is +/// bypassed. Injects the same augmented PATH used for launched agents so +/// script shims with `/usr/bin/env ` shebangs can find runtimes +/// such as node/python when the app was launched with a bare GUI PATH. +pub(crate) fn login_probe( + binary_path: &Path, + probe_args: &[&str], + augmented_path: Option<&str>, +) -> ProbeOutcome { + let mut command = std::process::Command::new(binary_path); + command.args(&probe_args[1..]); + if let Some(path) = augmented_path { + command.env("PATH", path); + } + + match command.output() { + Ok(o) if o.status.success() => ProbeOutcome::LoggedIn, + Ok(o) => classify_probe_output(&o.stderr, false), + Err(_) => ProbeOutcome::LoggedOut, + } +} + +/// Classify collected probe output into a `ProbeOutcome`. +/// +/// Shared between `login_probe` (which has the full `Output`) and the +/// process-level timeout path in `probe_auth_status` (which drains stderr +/// on a background thread and collects it separately). +pub(crate) fn classify_probe_output(stderr_bytes: &[u8], exit_success: bool) -> ProbeOutcome { + if exit_success { + return ProbeOutcome::LoggedIn; + } + let stderr = String::from_utf8_lossy(stderr_bytes); + let stderr_lower = stderr.to_lowercase(); + if CONFIG_PARSE_SIGNALS + .iter() + .all(|sig| stderr_lower.contains(sig)) + { + let excerpt = stderr.trim().lines().next().unwrap_or("").to_string(); + ProbeOutcome::ConfigInvalid { + stderr_excerpt: excerpt, + } + } else { + ProbeOutcome::LoggedOut + } +} + +#[cfg(test)] +mod tests { + use super::{ProbeOutcome, CONFIG_PARSE_SIGNALS}; + + #[cfg(unix)] + #[test] + fn login_probe_uses_augmented_path_for_env_shebang_interpreter() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + let temp = tempfile::tempdir().expect("temp dir"); + let script_dir = temp.path().join("script-bin"); + let interpreter_dir = temp.path().join("interpreter-bin"); + let empty_path_dir = temp.path().join("empty-bin"); + fs::create_dir_all(&script_dir).expect("script dir"); + fs::create_dir_all(&interpreter_dir).expect("interpreter dir"); + fs::create_dir_all(&empty_path_dir).expect("empty path dir"); + + let interpreter_path = interpreter_dir.join("node"); + let marker_path = temp.path().join("fake-node-ran"); + fs::write( + &interpreter_path, + format!( + "#!/bin/sh\nprintf 'fake node ran\\n' > '{}' || exit 1\nexit 0\n", + marker_path.display() + ), + ) + .expect("write interpreter"); + fs::set_permissions(&interpreter_path, fs::Permissions::from_mode(0o755)) + .expect("chmod interpreter"); + + let script_path = script_dir.join("fake-codex"); + fs::write(&script_path, "#!/usr/bin/env node\n").expect("write script"); + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let scrubbed_path = std::env::join_paths([empty_path_dir.as_path()]) + .expect("join scrubbed PATH") + .to_string_lossy() + .into_owned(); + let without_augmented_path = Command::new(&script_path) + .args(["login", "status"]) + .env("PATH", &scrubbed_path) + .output() + .expect("run script with scrubbed PATH"); + assert!( + !without_augmented_path.status.success(), + "with a scrubbed PATH, /usr/bin/env should not find node" + ); + + let augmented_path = + std::env::join_paths([interpreter_dir.as_path()]).expect("join augmented PATH"); + let augmented_path = augmented_path.to_string_lossy().into_owned(); + assert_eq!( + super::login_probe( + &script_path, + &["fake-codex", "login", "status"], + Some(&augmented_path), + ), + ProbeOutcome::LoggedIn, + "the injected augmented PATH should allow /usr/bin/env to find the interpreter" + ); + assert!( + marker_path.exists(), + "the fake node from the injected PATH should have run" + ); + } + + #[cfg(unix)] + #[test] + fn login_probe_config_invalid_on_stderr_signal() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + + // Script that exits 1 and writes a codex-style config-parse error to stderr. + let script_path = bin_dir.join("fake-codex-bad-config"); + fs::write( + &script_path, + "#!/bin/sh\necho 'Error loading configuration: /home/user/.codex/config.toml: unknown variant `ultra`, expected one of none/minimal/low/medium/high/xhigh' >&2\nexit 1\n", + ) + .expect("write script"); + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let outcome = super::login_probe( + &script_path, + &["fake-codex-bad-config", "login", "status"], + None, + ); + assert!( + matches!(outcome, ProbeOutcome::ConfigInvalid { .. }), + "stderr with 'unknown variant' should produce ConfigInvalid; got {:?}", + outcome + ); + if let ProbeOutcome::ConfigInvalid { stderr_excerpt } = outcome { + assert!( + stderr_excerpt.contains("unknown variant") + || stderr_excerpt.contains("Error loading"), + "stderr_excerpt should contain the parse error: {stderr_excerpt}" + ); + } + } + + #[cfg(unix)] + #[test] + fn login_probe_logged_out_on_nonzero_without_config_signal() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + + // Script that exits 1 with a generic "not logged in" message. + let script_path = bin_dir.join("fake-codex-logged-out"); + fs::write( + &script_path, + "#!/bin/sh\necho 'not authenticated' >&2\nexit 1\n", + ) + .expect("write script"); + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let outcome = super::login_probe( + &script_path, + &["fake-codex-logged-out", "login", "status"], + None, + ); + assert_eq!( + outcome, + ProbeOutcome::LoggedOut, + "non-config stderr should produce LoggedOut" + ); + } + + /// Verify that every string in CONFIG_PARSE_SIGNALS is lowercased so the + /// case-insensitive match works correctly. + #[test] + fn config_parse_signals_are_lowercase() { + for sig in CONFIG_PARSE_SIGNALS { + assert_eq!( + *sig, + sig.to_lowercase(), + "CONFIG_PARSE_SIGNAL must be lowercase for case-insensitive matching: {sig}" + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs new file mode 100644 index 0000000000..dc73bc9739 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -0,0 +1,139 @@ +//! Boot-time disk↔relay reconcile for managed-agent (kind:30177) events. +//! +//! `run_event_sync` already reconciles personas (30175) and teams (30176) +//! into the retention store at boot; managed agents were the missing leg — +//! their events were enqueued only on the interactive save path +//! (`retain_managed_agent_pending`), so a record edited on disk between +//! launches, or a save whose publish was missed, silently diverged from the +//! relay. This module mirrors `migrate_personas_in_dir`: per-coordinate +//! content diff, monotonic `created_at` bump, retain with `pending_sync = 1` +//! for the existing flush loop. +//! +//! Best-effort contract (decided in #centralize-personas-and-agents): +//! - No file watcher — hand edits are picked up at next boot only. +//! - No deletion reconcile — a record absent from `managed-agents.json` is +//! left untouched in retention; a truncated or partial file must never +//! trigger tombstones. +//! - A malformed store fails loudly: the broken file is preserved as +//! `managed-agents.json.invalid` (see [`super::storage::backup_invalid_store`]) +//! and an error is returned, never silently skipped. + +use std::path::Path; + +use super::{ + agent_events::build_agent_event, + persona_events::monotonic_created_at, + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + ManagedAgentRecord, +}; +use buzz_core_pkg::kind::KIND_MANAGED_AGENT; +use nostr::JsonUtil; + +/// Reconcile `managed-agents.json` into kind:30177 events in the retention +/// store. Boot-time entry point, called from `event_sync::run_event_sync` +/// after the persona and team legs. +pub(crate) fn reconcile_agents_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { + let Ok(base_dir) = super::managed_agents_base_dir(app) else { + return; + }; + + match reconcile_agents_in_dir(&base_dir, keys) { + Ok(0) => {} + Ok(reconciled) => { + eprintln!( + "buzz-desktop: agent-event-reconcile: {reconciled} agents reconciled to retention" + ); + } + Err(e) => { + eprintln!("buzz-desktop: agent-event-reconcile: {e}"); + } + } +} + +/// Core reconcile logic, decoupled from the Tauri `AppHandle` for testing. +/// +/// Reads `managed-agents.json` raw — no keyring hydration: the published +/// projection ([`super::agent_events::agent_event_content`]) is the opt-IN +/// no-secrets allowlist, so keys are never needed here. For each record it +/// compares the freshly built event's content against the retained row at +/// `(30177, owner, agent_pubkey)` and re-retains (marking `pending_sync = 1`) +/// only when the row is absent or its content differs — an unchanged agent +/// never churns `pending_sync`. +/// +/// Returns the number of agents (re)written to the retention store. +pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + let store_path = base_dir.join("managed-agents.json"); + if !store_path.exists() { + return Ok(0); + } + + let content = std::fs::read_to_string(&store_path) + .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; + + let records: Vec = serde_json::from_str(&content).map_err(|e| { + super::storage::backup_invalid_store(&store_path); + format!("failed to parse managed-agents.json (preserved as .invalid): {e}") + })?; + + if records.is_empty() { + return Ok(0); + } + + let owner_pubkey = keys.public_key().to_hex(); + + let db_path = base_dir.join("retention.db"); + let conn = + open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; + + let mut reconciled = 0u32; + + for record in &records { + // A record without a pubkey has no event coordinate yet (key-less + // agents mint keys on first start) — nothing to reconcile. + if record.pubkey.is_empty() { + continue; + } + + let existing = + get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; + + // Build the event first and compare ITS content, so the comparison and + // the retained row share one serialization of the projection (mirrors + // `migrate_personas_in_dir`). Serializing the projection independently + // here would silently diverge if `build_agent_event` ever changed how + // it serializes — republishing every agent every boot. Content is + // timestamp-independent, so the monotonic bump below never forces a + // spurious republish; an unchanged agent is still a true no-op. + let event = build_agent_event(record)? + .custom_created_at(monotonic_created_at( + existing.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event for '{}': {e}", record.name))?; + + let content = event.content.clone(); + if existing.as_ref().is_some_and(|row| row.content == content) { + continue; + } + + retain_event( + &conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner_pubkey.clone(), + d_tag: record.pubkey.clone(), + content, + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .map_err(|e| format!("failed to retain '{}': {e}", record.name))?; + reconciled += 1; + } + + Ok(reconciled) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs new file mode 100644 index 0000000000..5af05f99d6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -0,0 +1,302 @@ +use super::*; +use crate::managed_agents::retention::{get_pending_sync, get_retained_event, mark_synced}; +use std::collections::BTreeMap; +use tempfile::TempDir; + +fn sample_record(pubkey: &str, name: &str) -> ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "{pubkey}", + "name": "{name}", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "You are a test agent.", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }}"# + )) + .unwrap() +} + +fn write_store(dir: &TempDir, records: &[ManagedAgentRecord]) { + std::fs::write( + dir.path().join("managed-agents.json"), + serde_json::to_vec_pretty(records).unwrap(), + ) + .unwrap(); +} + +#[test] +fn missing_store_is_noop() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0); +} + +#[test] +fn fresh_record_is_retained_pending() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + write_store(&dir, &[sample_record("a".repeat(64).as_str(), "agent-one")]); + + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1); + + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].kind, KIND_MANAGED_AGENT); + assert_eq!(pending[0].d_tag, "a".repeat(64)); + // The retained content is the opt-IN projection — never secrets. + assert!(!pending[0].raw_event.contains("nsec")); +} + +#[test] +fn unchanged_record_does_not_churn_pending_sync() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + write_store(&dir, &[sample_record("b".repeat(64).as_str(), "agent-two")]); + + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1); + + // Simulate the flush loop confirming the publish. + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let row = get_retained_event( + &conn, + KIND_MANAGED_AGENT, + &keys.public_key().to_hex(), + &"b".repeat(64), + ) + .unwrap() + .unwrap(); + mark_synced( + &conn, + row.kind, + &row.pubkey, + &row.d_tag, + row.created_at, + &row.content, + ) + .unwrap(); + drop(conn); + + // Second boot with identical disk state: no re-retain, no pending churn. + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + assert!(get_pending_sync(&conn).unwrap().is_empty()); +} + +#[test] +fn edited_record_is_republished() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let mut record = sample_record("c".repeat(64).as_str(), "agent-three"); + write_store(&dir, &[record.clone()]); + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1); + + // Hand-edit a published field between launches. + record.system_prompt = Some("You are an edited agent.".to_string()); + write_store(&dir, &[record]); + + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let row = get_retained_event( + &conn, + KIND_MANAGED_AGENT, + &keys.public_key().to_hex(), + &"c".repeat(64), + ) + .unwrap() + .unwrap(); + assert!(row.content.contains("edited agent")); + assert!(row.pending_sync); +} + +#[test] +fn excluded_field_edit_is_noop() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let mut record = sample_record("d".repeat(64).as_str(), "agent-four"); + write_store(&dir, &[record.clone()]); + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1); + + // env_vars is excluded from the projection — editing it must not republish. + record.env_vars = BTreeMap::from([("SOME_KEY".to_string(), "value".to_string())]); + write_store(&dir, &[record]); + + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0); +} + +#[test] +fn missing_record_is_never_tombstoned() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let one = sample_record("e".repeat(64).as_str(), "agent-five"); + let two = sample_record("f".repeat(64).as_str(), "agent-six"); + write_store(&dir, &[one.clone(), two]); + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 2); + + // A truncated store (one of two records) must leave the missing record's + // retained row untouched — absence never tombstones. + write_store(&dir, &[one]); + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0); + + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let survivor = get_retained_event( + &conn, + KIND_MANAGED_AGENT, + &keys.public_key().to_hex(), + &"f".repeat(64), + ) + .unwrap(); + assert!(survivor.is_some(), "missing record must stay retained"); +} + +#[test] +fn keyless_record_is_skipped() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + write_store(&dir, &[sample_record("", "keyless-agent")]); + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 0); +} + +#[test] +fn malformed_store_errors_and_preserves_invalid_backup() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let store_path = dir.path().join("managed-agents.json"); + std::fs::write(&store_path, b"[{ this is not json").unwrap(); + + let err = reconcile_agents_in_dir(dir.path(), &keys).unwrap_err(); + assert!(err.contains("failed to parse"), "unexpected error: {err}"); + + let backup = dir.path().join("managed-agents.json.invalid"); + assert!(backup.exists(), "malformed store must be preserved"); + assert_eq!( + std::fs::read(&backup).unwrap(), + b"[{ this is not json".to_vec() + ); + // Original stays in place so the next boot fails loudly again. + assert!(store_path.exists()); +} + +#[test] +fn monotonic_bump_supersedes_future_dated_head() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let mut record = sample_record("1".repeat(64).as_str(), "agent-seven"); + write_store(&dir, &[record.clone()]); + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1); + + // Future-date the retained head (clock skew / interactive same-second bump). + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let owner = keys.public_key().to_hex(); + let head = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner, &"1".repeat(64)) + .unwrap() + .unwrap(); + let future = RetainedEvent { + created_at: head.created_at + 3600, + ..head + }; + crate::managed_agents::retention::retain_event(&conn, &future).unwrap(); + drop(conn); + + record.system_prompt = Some("New prompt after skew.".to_string()); + write_store(&dir, &[record]); + + // The changed body must land despite the future-dated head. + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let row = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner, &"1".repeat(64)) + .unwrap() + .unwrap(); + assert!(row.content.contains("New prompt after skew")); +} + +/// The slimming transition: a definition-linked record whose retained row +/// holds the legacy fat projection republishes ONCE (the slimmed shape), and +/// the second boot is a true no-op — the republish wave is one-time. +#[test] +fn slimming_republish_wave_is_one_time() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + let mut record = sample_record("e".repeat(64).as_str(), "agent-five"); + record.persona_id = Some("persona-1".to_string()); + record.persona_source_version = Some("abc123".to_string()); + write_store(&dir, &[record]); + + // Seed a SYNCED legacy-fat retained row — the pre-upgrade state — so the + // first-boot republish below is distinctly the fat→slim content change, + // not the ordinary fresh-record retain. + let fat_content = serde_json::json!({ + "name": "agent-five", + "persona_id": "persona-1", + "system_prompt": "You are a test agent.", + "persona_source_version": "abc123", + "parallelism": 1, + "respond_to": "owner-only" + }) + .to_string(); + { + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: keys.public_key().to_hex(), + d_tag: "e".repeat(64), + content: fat_content, + created_at: 1, + raw_event: String::new(), + pending_sync: false, + }, + ) + .unwrap(); + } + + // First boot after upgrade: projection content changed (fat -> slim) so + // the agent republishes. + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let row = get_retained_event( + &conn, + KIND_MANAGED_AGENT, + &keys.public_key().to_hex(), + &"e".repeat(64), + ) + .unwrap() + .unwrap(); + assert!( + !row.content.contains("system_prompt"), + "definition-linked retained content must be the slimmed shape" + ); + assert!(!row.content.contains("\"model\""), "model must be slimmed"); + assert!( + !row.content.contains("\"provider\""), + "provider must be slimmed" + ); + assert!( + !row.content.contains("persona_source_version"), + "persona_source_version must be slimmed" + ); + assert!( + !row.content.contains("abc123"), + "source version value must be absent" + ); + assert!(row.pending_sync, "slimmed rewrite must queue for publish"); + drop(conn); + + // Second boot: identical projection — a true no-op, no republish loop. + assert_eq!( + reconcile_agents_in_dir(dir.path(), &keys).unwrap(), + 0, + "second boot must be a no-op (idempotence)" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 22ab9ad3b3..a47122df80 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -1,8 +1,50 @@ -use super::ManagedAgentRecord; -pub use super::RelayMeshConfig; +#[cfg(feature = "mesh-llm")] +use super::{ManagedAgentRecord, RelayMeshConfig}; pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1"; pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; +pub const RELAY_MESH_PROVIDER_ID: &str = "relay-mesh"; +pub const RELAY_MESH_AUTO_MODEL_ID: &str = "auto"; + +/// Translate the native Buzz shared compute provider into the OpenAI-compatible +/// transport understood by buzz-agent. These are derived runtime details, not +/// user-owned agent configuration. +#[cfg(feature = "mesh-llm")] +pub fn apply_relay_mesh_env( + env: &mut std::collections::BTreeMap, + provider: Option<&str>, + model: Option<&str>, +) { + if provider.map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { + return; + } + let model = model + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) + .to_string(); + env.insert("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()); + env.insert("BUZZ_AGENT_MODEL".to_string(), model.clone()); + env.insert( + "OPENAI_COMPAT_BASE_URL".to_string(), + RELAY_MESH_API_BASE_URL.to_string(), + ); + env.insert("OPENAI_COMPAT_MODEL".to_string(), model); + env.insert( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ); + env.insert("OPENAI_COMPAT_API".to_string(), "chat".to_string()); + // Keep the requested response inside smaller local-model context windows, + // and spend that budget on an answer/tool call instead of hidden reasoning. + // Without both settings Qwen3 either fails the router's fit check at the + // agent default (32K) or can consume a tight cap before serializing a tool. + env.insert( + "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), + "4096".to_string(), + ); + env.insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "none".to_string()); +} /// Resolve a record's relay-mesh config, typed field first. /// @@ -12,6 +54,16 @@ pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; /// field and need no env-var sniffing at all. #[cfg(feature = "mesh-llm")] pub fn relay_mesh_config(record: &ManagedAgentRecord) -> Option { + if record.provider.as_deref() == Some(RELAY_MESH_PROVIDER_ID) { + let model_ref = record + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) + .to_string(); + return Some(RelayMeshConfig { model_ref }); + } if let Some(config) = &record.relay_mesh { return Some(config.clone()); } @@ -53,12 +105,12 @@ fn relay_mesh_model_id_from_env(record: &ManagedAgentRecord) -> Option { .map(str::to_string) } -#[cfg(test)] +#[cfg(all(test, feature = "mesh-llm"))] mod tests { use std::collections::BTreeMap; use super::*; - use crate::managed_agents::{BackendKind, RespondTo}; + use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; fn fixture() -> ManagedAgentRecord { ManagedAgentRecord { @@ -80,13 +132,14 @@ mod tests { parallelism: 1, system_prompt: None, model: None, - mcp_toolsets: None, env_vars: BTreeMap::new(), start_on_app_launch: false, + auto_restart_on_config_change: true, runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, provider_binary_path: None, + team_id: None, persona_team_dir: None, persona_name_in_team: None, persona_source_version: None, @@ -97,13 +150,24 @@ mod tests { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: RespondTo::OwnerOnly, respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, relay_mesh: None, } } - #[cfg(feature = "mesh-llm")] #[test] fn relay_mesh_model_id_detects_mesh_preset_env() { let mut rec = fixture(); @@ -123,7 +187,25 @@ mod tests { assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); } - #[cfg(feature = "mesh-llm")] + #[test] + fn native_provider_uses_context_safe_non_reasoning_budget() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_MAX_OUTPUT_TOKENS").map(String::as_str), + Some("4096") + ); + assert_eq!( + env.get("BUZZ_AGENT_THINKING_EFFORT").map(String::as_str), + Some("none") + ); + } + #[test] fn relay_mesh_model_id_ignores_non_mesh_openai_env() { let mut rec = fixture(); @@ -139,7 +221,6 @@ mod tests { assert_eq!(relay_mesh_model_id(&rec), None); } - #[cfg(feature = "mesh-llm")] #[test] fn relay_mesh_model_id_ignores_user_openai_on_same_local_port() { let mut rec = fixture(); @@ -156,15 +237,13 @@ mod tests { assert_eq!(relay_mesh_model_id(&rec), None); } - #[cfg(feature = "mesh-llm")] #[test] - fn typed_field_recognized_with_zero_env_vars() { + fn native_provider_fields_are_authoritative() { // The whole point: a typed record needs no env-var sniffing to be // recognized as a relay-mesh agent. let mut rec = fixture(); - rec.relay_mesh = Some(RelayMeshConfig { - model_ref: "Qwen3".to_string(), - }); + rec.provider = Some(RELAY_MESH_PROVIDER_ID.to_string()); + rec.model = Some("Qwen3".to_string()); assert!(rec.env_vars.is_empty()); assert_eq!( relay_mesh_config(&rec), @@ -175,11 +254,11 @@ mod tests { assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); } - #[cfg(feature = "mesh-llm")] #[test] - fn typed_field_wins_over_env_sniff() { - // When both are present, the typed field is authoritative. + fn native_provider_fields_win_over_legacy_config() { let mut rec = fixture(); + rec.provider = Some(RELAY_MESH_PROVIDER_ID.to_string()); + rec.model = Some("native-model".to_string()); rec.relay_mesh = Some(RelayMeshConfig { model_ref: "typed-model".to_string(), }); @@ -195,10 +274,9 @@ mod tests { RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), ), ]); - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("typed-model")); + assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("native-model")); } - #[cfg(feature = "mesh-llm")] #[test] fn legacy_record_falls_back_to_env_sniff() { // Records saved before the typed field still resolve via env vars. diff --git a/desktop/src-tauri/src/managed_agents/repos.rs b/desktop/src-tauri/src/managed_agents/repos.rs index 1228ad74cb..1c77c5722e 100644 --- a/desktop/src-tauri/src/managed_agents/repos.rs +++ b/desktop/src-tauri/src/managed_agents/repos.rs @@ -9,6 +9,9 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +#[cfg(unix)] +use crate::util::{create_symlink, symlink_points_to}; + /// Validate a user-supplied `repos_dir`, returning the canonical target path. /// /// Requires an **existing absolute directory**. Rejects relative paths, @@ -98,7 +101,7 @@ pub fn ensure_repos_symlink(nest_root: &Path, repos_dir: Option<&str>) -> Result // Existing symlink → replace it if it points elsewhere. Re-pointing a // symlink is data-safe; remove_file never follows the link. Ok(meta) if meta.file_type().is_symlink() => { - if repos_path.read_link().ok().as_deref() == Some(target.as_path()) { + if symlink_points_to(&repos_path, &target) { return Ok(()); // already correct } fs::remove_file(&repos_path) @@ -134,7 +137,7 @@ pub fn ensure_repos_symlink(nest_root: &Path, repos_dir: Option<&str>) -> Result #[cfg(unix)] fn symlink_repos(target: &Path, link: &Path) -> Result<(), String> { - std::os::unix::fs::symlink(target, link) + create_symlink(target, link) .map_err(|e| format!("symlink {} → {}: {e}", link.display(), target.display())) } diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 85f5391128..e4fc18b39f 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -19,6 +19,10 @@ type AgentSpawnResult = (String, SpawnResult); /// empty snapshot. /// /// Only records with a `persona_id` but no `persona_source_version` are touched. +/// Records that already have a `persona_source_version` — including those whose +/// `model`/`provider` were clobbered by the old unconditional snapshot code before +/// this fix — are skipped here; they self-heal on the next manual start via the +/// start-path re-snapshot in `start_local_agent_with_preflight`. /// If the linked persona is gone, we log loudly and leave the snapshot empty — /// the record's own `system_prompt`/`model` (possibly empty for persona-created /// agents) is then all the config that remains, which is the same fallback an @@ -54,16 +58,11 @@ pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> ); continue; }; - // Layer the agent's own env overrides over persona env, matching - // create-time precedence (persona env < agent env). - let snapshot = super::persona_events::persona_snapshot(persona, &record.env_vars); - if let Some(prompt) = snapshot.system_prompt { - record.system_prompt = Some(prompt); - } - record.model = snapshot.model; - record.provider = snapshot.provider; - record.env_vars = snapshot.env_vars; - record.persona_source_version = Some(snapshot.source_version); + // Layer precedence at read time: persona env < agent env. When the + // persona leaves model/provider blank, the record's own configured + // values are preserved — a blank persona must not clobber a + // user-configured agent. See `apply_persona_snapshot`. + super::persona_events::apply_persona_snapshot(record, persona); record.updated_at = util::now_iso(); changed = true; } @@ -132,6 +131,17 @@ pub async fn restore_managed_agents_on_launch( // whose desktop process is no longer running and reap them. super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids); + // Exact-path sweep: kill any buzz-acp process whose executable path + // matches this bundle's harness binary but is not in the tracked set. + // Complements the env-var sweep above — catches orphans that predate + // BUZZ_MANAGED_AGENT injection or lost their PID-file receipt. + // + // TODO: the three sweeps above each walk the PID table independently. + // A future consolidation should collect a single shared process snapshot + // at the top of this block and thread it through all sweep functions, + // replacing the three separate kernel enumerations. + super::sweep_untracked_bundle_harnesses(&tracked_pids); + let candidates: Vec = records .iter() .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) @@ -170,14 +180,7 @@ pub async fn restore_managed_agents_on_launch( let Some(persona) = personas_for_snapshot.iter().find(|p| p.id == persona_id) else { continue; }; - let snapshot = super::persona_events::persona_snapshot(persona, &record.env_vars); - if let Some(prompt) = snapshot.system_prompt { - record.system_prompt = Some(prompt); - } - record.model = snapshot.model; - record.provider = snapshot.provider; - record.env_vars = snapshot.env_vars; - record.persona_source_version = Some(snapshot.source_version); + super::persona_events::apply_persona_snapshot(record, persona); record.updated_at = util::now_iso(); changed = true; } @@ -233,6 +236,18 @@ pub async fn restore_managed_agents_on_launch( return Ok(()); } + // Serialize spawning and runtime registration with shutdown cleanup. The + // shutdown flag is rechecked after taking the lock so shutdown either + // prevents this transition or waits until every child is tracked and can + // be terminated. + let restore_transition = state + .managed_agent_restore_transition + .lock() + .map_err(|error| error.to_string())?; + if shutdown_started.load(Ordering::SeqCst) { + return Ok(()); + } + // ── Phase B (no locks): resolve commands and spawn processes in parallel ── let spawn_results: Vec = std::thread::scope(|scope| { let owner_hex_ref = owner_hex.as_deref(); @@ -306,11 +321,8 @@ pub async fn restore_managed_agents_on_launch( // Resolve the effective harness for the avatar-fallback // derivation (the snapshot may be empty/stale for an inherited // harness). Mirrors the UI start path. - let effective_command = crate::managed_agents::effective_agent_command( - record.persona_id.as_deref(), - &reconcile_personas, - record.agent_command_override.as_deref(), - ); + let effective_command = + crate::managed_agents::record_agent_command(record, &reconcile_personas); Some(( pubkey.clone(), crate::commands::ProfileReconcileData { @@ -328,6 +340,9 @@ pub async fn restore_managed_agents_on_launch( .collect(); save_managed_agents(app, &records)?; + drop(runtimes); + drop(_store_guard); + drop(restore_transition); // ── Profile reconciliation (fire-and-forget) ──────────────────────────── // Spawn background tasks to ensure each restored agent's kind:0 profile is diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 1ba5d14a5e..5df566dbbe 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -6,6 +6,7 @@ //! `created_at` for NIP-33 latest-wins semantics. use std::path::Path; +use std::time::{Duration, Instant}; use rusqlite::{params, Connection, OptionalExtension}; @@ -29,10 +30,9 @@ pub struct RetainedEvent { pub fn open_retention_db(path: &Path) -> Result { let conn = Connection::open(path).map_err(|e| format!("failed to open retention db: {e}"))?; - conn.pragma_update(None, "journal_mode", "WAL") - .map_err(|e| format!("failed to set WAL mode: {e}"))?; conn.pragma_update(None, "busy_timeout", 5000) .map_err(|e| format!("failed to set busy_timeout: {e}"))?; + set_wal_mode(&conn)?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS persona_events ( @@ -51,6 +51,30 @@ pub fn open_retention_db(path: &Path) -> Result { Ok(conn) } +fn set_wal_mode(conn: &Connection) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match conn.pragma_update(None, "journal_mode", "WAL") { + Ok(()) => return Ok(()), + Err(error) if sqlite_is_busy(&error) && Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(25)); + } + Err(error) => return Err(format!("failed to set WAL mode: {error}")), + } + } +} + +fn sqlite_is_busy(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(inner, _) + if matches!( + inner.code, + rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked + ) + ) +} + /// Build the retention `d_tag` column value for a kind:5 tombstone row. /// /// Tombstones for all target kinds share `kind = 5` in the retention table, so @@ -64,6 +88,26 @@ pub fn tombstone_retention_d_tag(target_kind: u32, d_tag: &str) -> String { format!("{target_kind}:{d_tag}") } +/// Whether a pending row must be deferred to the next sweep because a kind:5 +/// tombstone covering its coordinate failed to publish earlier in the same +/// sweep. +/// +/// `get_pending_sync` orders tombstones first so a deletion always reaches the +/// relay before the replacement that supersedes it — but the flush is +/// best-effort per row, so a tombstone that fails mid-sweep would otherwise be +/// leapfrogged by its own replacement and then wipe it on the next sweep. +/// Deferring the replacement restores the ordering guarantee: next sweep the +/// `ORDER BY` puts the tombstone first again. Kind:5 rows are never deferred. +pub fn deferred_behind_failed_tombstone( + kind: u32, + pubkey: &str, + d_tag: &str, + failed_tombstones: &std::collections::HashSet<(String, String)>, +) -> bool { + kind != 5 + && failed_tombstones.contains(&(pubkey.to_string(), tombstone_retention_d_tag(kind, d_tag))) +} + /// Upsert a persona event into the retention store. /// /// Only replaces if the new event has a newer or equal `created_at` (NIP-33 semantics). @@ -198,12 +242,20 @@ pub fn get_retained_personas( } /// Get all events marked as pending sync (not yet confirmed on relay). +/// +/// Tombstones (kind:5) sort FIRST: a delete retained in one session and its +/// coordinate's replacement retained in a later one (B5 backfill resurrecting +/// a deleted definition) must publish in that order — the relay's a-tag +/// deletion soft-deletes every live row at the coordinate with no timestamp +/// comparison, so a tombstone published AFTER the replacement would wipe it. +/// Within each group, oldest first for the same reason. pub fn get_pending_sync(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync FROM persona_events - WHERE pending_sync = 1", + WHERE pending_sync = 1 + ORDER BY (kind != 5), created_at ASC", ) .map_err(|e| format!("failed to prepare pending sync query: {e}"))?; @@ -316,6 +368,21 @@ pub fn get_retained_event( mod tests { use super::*; + #[test] + fn concurrent_open_waits_for_initialization_lock() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("retention.db"); + let first = open_retention_db(&path).unwrap(); + first.execute_batch("BEGIN EXCLUSIVE").unwrap(); + + let second_path = path.clone(); + let second = std::thread::spawn(move || open_retention_db(&second_path)); + std::thread::sleep(std::time::Duration::from_millis(100)); + first.execute_batch("COMMIT").unwrap(); + + assert!(second.join().unwrap().is_ok()); + } + fn test_db() -> Connection { open_retention_db(Path::new(":memory:")).unwrap() } @@ -630,4 +697,84 @@ mod tests { assert_eq!(row.created_at, 2000); assert!(!row.content.contains("Stale")); } + + #[test] + fn pending_sync_publishes_tombstones_before_replacements() { + // B5 resurrection race: a kind:5 retained in session N and the same + // coordinate's replacement 30175 retained on the next boot can sit + // pending together. The relay's a-tag deletion ignores timestamps, + // so the tombstone MUST publish first or it wipes the replacement. + let conn = test_db(); + let replacement = RetainedEvent { + kind: 30175, + created_at: 2000, + pending_sync: true, + ..sample_event() + }; + retain_event(&conn, &replacement).unwrap(); + let tombstone = RetainedEvent { + kind: 5, + d_tag: tombstone_retention_d_tag(30175, "test-persona"), + content: String::new(), + created_at: 1000, + pending_sync: true, + ..sample_event() + }; + retain_event(&conn, &tombstone).unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].kind, 5, "tombstone first"); + assert_eq!(pending[1].kind, 30175, "replacement second"); + } + + #[test] + fn deferral_predicate_is_kind_and_pubkey_qualified() { + // Mid-sweep barrier semantics: a failed tombstone defers ONLY the + // replacement at its exact coordinate — same target kind, same pubkey. + use std::collections::HashSet; + + let failed: HashSet<(String, String)> = HashSet::from([( + "abc123".to_string(), + tombstone_retention_d_tag(30175, "test-persona"), + )]); + + // The covered replacement defers. + assert!(deferred_behind_failed_tombstone( + 30175, + "abc123", + "test-persona", + &failed + )); + // Kind-qualified: a coinciding slug under a DIFFERENT kind is a + // distinct coordinate (the cross-kind collision the retention d-tag + // encoding exists to prevent) — never deferred. + assert!(!deferred_behind_failed_tombstone( + 30177, + "abc123", + "test-persona", + &failed + )); + // Never crosses pubkeys. + assert!(!deferred_behind_failed_tombstone( + 30175, + "other-key", + "test-persona", + &failed + )); + // Never defers kind:5 rows, even at a "matching" retention key. + assert!(!deferred_behind_failed_tombstone( + 5, + "abc123", + "test-persona", + &failed + )); + // Unrelated d-tags publish normally. + assert!(!deferred_behind_failed_tombstone( + 30175, + "abc123", + "other-persona", + &failed + )); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index de38c3b1bc..2c15813e48 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -14,7 +14,10 @@ use crate::{ }; mod path; -use path::build_augmented_path; +pub(in crate::managed_agents) use path::build_augmented_path; + +mod sweep; +pub(crate) use sweep::sweep_untracked_bundle_harnesses; type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); @@ -66,9 +69,7 @@ fn name_matches_known_binary(name: &str) -> bool { /// a managed agent wrapper (e.g. `node` running an npm shim for `codex-acp`). /// Callers must additionally verify `BUZZ_MANAGED_AGENT` ownership. fn name_matches_interpreter(name: &str) -> bool { - KNOWN_SCRIPT_INTERPRETERS - .iter() - .any(|&interp| name == interp) + KNOWN_SCRIPT_INTERPRETERS.contains(&name) } #[cfg(unix)] @@ -169,40 +170,9 @@ fn buzz_marker_entry(instance_id: &str) -> Vec { #[cfg(target_os = "macos")] fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { let marker = buzz_marker_entry(instance_id); - - let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int]; - let mut buf_size: libc::size_t = 0; - - // First call: get required buffer size. - if unsafe { - libc::sysctl( - mib.as_mut_ptr(), - 3, - std::ptr::null_mut(), - &mut buf_size, - std::ptr::null_mut(), - 0, - ) - } != 0 - { + let Some(buf) = sweep::procargs2_buffer(pid) else { return false; - } - - let mut buf: Vec = vec![0; buf_size]; - if unsafe { - libc::sysctl( - mib.as_mut_ptr(), - 3, - buf.as_mut_ptr() as *mut libc::c_void, - &mut buf_size, - std::ptr::null_mut(), - 0, - ) - } != 0 - { - return false; - } - buf.truncate(buf_size); + }; // Buffer layout: [i32 argc][exec_path\0][null padding][argv\0...][env\0...] if buf.len() < std::mem::size_of::() { @@ -379,6 +349,7 @@ fn resolve_pgids_and_kill(candidate_pids: &[i32]) { // PID-recycling guard: if a resolved PGID is alive but isn't one of our // orphan candidates, the old harness PID was recycled by a new process // that called setsid() — skip it to avoid killing an unrelated group. + let candidate_groups = pgids.len(); pgids.retain(|&pgid| { if candidate_set.contains(&pgid) { return true; @@ -386,6 +357,11 @@ fn resolve_pgids_and_kill(candidate_pids: &[i32]) { let alive = unsafe { libc::kill(pgid, 0) } == 0; !alive }); + if pgids.is_empty() && candidate_groups > 0 { + eprintln!( + "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" + ); + } let unique: Vec = pgids.into_iter().collect(); sigterm_then_sigkill(&unique); } @@ -407,6 +383,7 @@ fn resolve_pgids_and_kill(candidate_pids: &[i32]) { // PID-recycling guard: if a resolved PGID is alive but isn't one of our // orphan candidates, the old harness PID was recycled by a new process // that called setsid() — skip it to avoid killing an unrelated group. + let candidate_groups = pgids.len(); pgids.retain(|&pgid| { if candidate_set.contains(&pgid) { return true; @@ -414,6 +391,11 @@ fn resolve_pgids_and_kill(candidate_pids: &[i32]) { let alive = unsafe { libc::kill(pgid, 0) } == 0; !alive }); + if pgids.is_empty() && candidate_groups > 0 { + eprintln!( + "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" + ); + } let unique: Vec = pgids.into_iter().collect(); sigterm_then_sigkill(&unique); } @@ -463,11 +445,16 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32] } // ── macOS process-info FFI (shared by all sweep/reap functions) ────────── +// +// `proc_listallpids` lives in `sweep.rs` (which owns `collect_all_pids`). +// All callers in this file reach it through `sweep::collect_all_pids()`. +// `proc_pidinfo` and `BSDInfo` are declared here as `pub(super)` so that +// `sweep.rs` can call `super::proc_pidinfo` / use `super::BSDInfo` without +// redefining the struct layout in two places. #[cfg(target_os = "macos")] extern "C" { - fn proc_listallpids(buffer: *mut libc::c_int, buffersize: libc::c_int) -> libc::c_int; - fn proc_pidinfo( + pub(super) fn proc_pidinfo( pid: libc::c_int, flavor: libc::c_int, arg: u64, @@ -480,11 +467,11 @@ extern "C" { /// against the macOS SDK — total size 136 bytes. #[cfg(target_os = "macos")] #[repr(C)] -struct BSDInfo { +pub(super) struct BSDInfo { _flags_status_xstatus: [u8; 12], // pbi_flags + pbi_status + pbi_xstatus - pbi_pid: u32, // offset 12 - pbi_ppid: u32, // offset 16 - pbi_uid: u32, // offset 20 + pub(super) pbi_pid: u32, // offset 12 + pub(super) pbi_ppid: u32, // offset 16 + pub(super) pbi_uid: u32, // offset 20 _rest: [u8; 112], } @@ -492,7 +479,7 @@ struct BSDInfo { const _: () = assert!(std::mem::size_of::() == 136); #[cfg(target_os = "macos")] -const PROC_PIDTBSDINFO: libc::c_int = 3; +pub(super) const PROC_PIDTBSDINFO: libc::c_int = 3; /// Enumerate all processes on the system owned by the current user and kill any /// agent binary stamped with *this* instance's `BUZZ_MANAGED_AGENT` marker @@ -503,33 +490,10 @@ const PROC_PIDTBSDINFO: libc::c_int = 3; #[cfg(target_os = "macos")] pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { let my_uid = unsafe { libc::getuid() }; - - // Loop until the buffer is large enough to hold all PIDs. Under a fork - // storm the process table can outgrow the initial estimate between the - // probe and the fill call. - let mut pids: Vec; - loop { - let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) }; - if count <= 0 { - return; - } - let buf_len = (count as usize) * 2; - pids = vec![0; buf_len]; - let actual = unsafe { - proc_listallpids( - pids.as_mut_ptr(), - (buf_len * std::mem::size_of::()) as libc::c_int, - ) - }; - if actual <= 0 { - return; - } - pids.truncate(actual as usize); - if (actual as usize) < buf_len { - break; - } + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return; } - let my_pid = std::process::id() as i32; let mut orphans: Vec = Vec::new(); @@ -563,20 +527,13 @@ pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) if info.pbi_uid != my_uid { continue; } - // Live child of a tracked harness — not an orphan. - if skip_pids.contains(&info.pbi_ppid) { - continue; - } - // Grandchild check: the harness is spawned with process_group(0), so - // all descendants share its PGID. If this process's PGID matches a - // tracked harness PID, it's a live descendant — not an orphan. - let pgid = unsafe { libc::getpgid(pid) }; - if pgid > 0 && skip_pids.contains(&(pgid as u32)) { - continue; - } if !process_has_buzz_marker(upid, instance_id) { continue; } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { + continue; + } orphans.push(pid); } @@ -589,27 +546,12 @@ pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) } } -/// Read the parent PID of a process from /proc//stat. -/// The comm field (field 2) may contain spaces and parens, so we find the last -/// ')' and parse fields after it. Field 1 after ')' is state, field 2 is PPID. -#[cfg(all(unix, not(target_os = "macos")))] -fn read_ppid_linux(pid: u32) -> Option { - let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; - let after_comm = stat.rsplit_once(')')?.1; - // Fields after ')': " S ppid pgid ..." - let ppid_str = after_comm.split_whitespace().nth(1)?; - ppid_str.parse::().ok() -} - -/// Read the process group ID from /proc//stat. Same parsing strategy as -/// `read_ppid_linux` — field 3 after the closing ')' is the PGID. +/// Read the process group ID from /proc//stat by delegating to the shared +/// stat parser in `sweep`. Keeps a single parse site for the `/proc//stat` +/// field layout. #[cfg(all(unix, not(target_os = "macos")))] fn read_pgid_linux(pid: u32) -> Option { - let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; - let after_comm = stat.rsplit_once(')')?.1; - // Fields after ')': " S ppid pgid ..." - let pgid_str = after_comm.split_whitespace().nth(2)?; - pgid_str.parse::().ok() + sweep::proc_stat_ppid_pgid_linux(pid).map(|(_, pgid)| pgid) } #[cfg(all(unix, not(target_os = "macos")))] @@ -647,23 +589,9 @@ pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { continue; } - // Live child of a tracked harness — not an orphan. If /proc//stat - // is unreadable (process exiting, transient I/O error), we treat the - // process as orphaned — safe because an exiting process will disappear - // shortly, and the two-tick grace in the periodic path prevents acting - // on transient failures. - if let Some(ppid) = read_ppid_linux(upid) { - if skip_pids.contains(&ppid) { - continue; - } - } - // Grandchild check: the harness is spawned with process_group(0), so - // all descendants share its PGID. If this process's PGID matches a - // tracked harness PID, it's a live descendant — not an orphan. - if let Some(pgid) = read_pgid_linux(upid) { - if skip_pids.contains(&pgid) { - continue; - } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_linux(upid, skip_pids) { + continue; } orphans.push(pid); } @@ -729,27 +657,9 @@ pub(crate) fn collect_same_instance_orphans( let my_pid = std::process::id() as i32; let mut orphans = std::collections::HashSet::new(); - let mut pids: Vec; - loop { - let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) }; - if count <= 0 { - return orphans; - } - let buf_len = (count as usize) * 2; - pids = vec![0; buf_len]; - let actual = unsafe { - proc_listallpids( - pids.as_mut_ptr(), - (buf_len * std::mem::size_of::()) as libc::c_int, - ) - }; - if actual <= 0 { - return orphans; - } - pids.truncate(actual as usize); - if (actual as usize) < buf_len { - break; - } + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return orphans; } for &pid in &pids { @@ -780,20 +690,14 @@ pub(crate) fn collect_same_instance_orphans( if info.pbi_uid != my_uid { continue; } - // Live child of a tracked harness — not an orphan. - if skip_pids.contains(&info.pbi_ppid) { + if !process_has_buzz_marker(upid, instance_id) { continue; } - // Grandchild check: the harness is spawned with process_group(0), so - // all descendants share its PGID. If this process's PGID matches a - // tracked harness PID, it's a live descendant — not an orphan. - let pgid = unsafe { libc::getpgid(pid) }; - if pgid > 0 && skip_pids.contains(&(pgid as u32)) { + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { continue; } - if process_has_buzz_marker(upid, instance_id) { - orphans.insert(upid); - } + orphans.insert(upid); } orphans } @@ -835,22 +739,9 @@ pub(crate) fn collect_same_instance_orphans( if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { continue; } - // Live child of a tracked harness — not an orphan. If /proc//stat - // is unreadable (process exiting, transient I/O error), we treat the - // process as orphaned — safe because an exiting process will disappear - // shortly, and the two-tick grace prevents acting on transient failures. - if let Some(ppid) = read_ppid_linux(upid) { - if skip_pids.contains(&ppid) { - continue; - } - } - // Grandchild check: the harness is spawned with process_group(0), so - // all descendants share its PGID. If this process's PGID matches a - // tracked harness PID, it's a live descendant — not an orphan. - if let Some(pgid) = read_pgid_linux(upid) { - if skip_pids.contains(&pgid) { - continue; - } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_linux(upid, skip_pids) { + continue; } orphans.insert(upid); } @@ -904,39 +795,7 @@ fn buffer_contains_identifier(buf: &[u8], id: &[u8]) -> bool { #[cfg(target_os = "macos")] fn extract_buzz_marker_value(pid: u32) -> Option { let prefix = b"BUZZ_MANAGED_AGENT="; - - let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int]; - let mut buf_size: libc::size_t = 0; - - if unsafe { - libc::sysctl( - mib.as_mut_ptr(), - 3, - std::ptr::null_mut(), - &mut buf_size, - std::ptr::null_mut(), - 0, - ) - } != 0 - { - return None; - } - - let mut buf: Vec = vec![0; buf_size]; - if unsafe { - libc::sysctl( - mib.as_mut_ptr(), - 3, - buf.as_mut_ptr() as *mut libc::c_void, - &mut buf_size, - std::ptr::null_mut(), - 0, - ) - } != 0 - { - return None; - } - buf.truncate(buf_size); + let buf = sweep::procargs2_buffer(pid)?; if buf.len() < std::mem::size_of::() { return None; @@ -1009,27 +868,9 @@ fn desktop_is_alive_for_instance(instance_id: &str) -> bool { let my_uid = unsafe { libc::getuid() }; let identifier_bytes = instance_id.as_bytes(); - let mut pids: Vec; - loop { - let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) }; - if count <= 0 { - return false; - } - let buf_len = (count as usize) * 2; - pids = vec![0; buf_len]; - let actual = unsafe { - proc_listallpids( - pids.as_mut_ptr(), - (buf_len * std::mem::size_of::()) as libc::c_int, - ) - }; - if actual <= 0 { - return false; - } - pids.truncate(actual as usize); - if (actual as usize) < buf_len { - break; - } + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return false; } for &pid in &pids { @@ -1072,36 +913,9 @@ fn desktop_is_alive_for_instance(instance_id: &str) -> bool { } // Check if this desktop process's args/env contain the identifier. // The KERN_PROCARGS2 buffer holds argv + environ as null-delimited strings. - let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid]; - let mut buf_size: libc::size_t = 0; - if unsafe { - libc::sysctl( - mib.as_mut_ptr(), - 3, - std::ptr::null_mut(), - &mut buf_size, - std::ptr::null_mut(), - 0, - ) - } != 0 - { + let Some(args_buf) = sweep::procargs2_buffer(pid as u32) else { continue; - } - let mut args_buf: Vec = vec![0; buf_size]; - if unsafe { - libc::sysctl( - mib.as_mut_ptr(), - 3, - args_buf.as_mut_ptr() as *mut libc::c_void, - &mut buf_size, - std::ptr::null_mut(), - 0, - ) - } != 0 - { - continue; - } - args_buf.truncate(buf_size); + }; // Boundary-anchored search: the identifier in the config JSON is // followed by a non-identifier char (typically `"`). A raw substring // match would let `...app` match inside `...app.dev`. @@ -1168,27 +982,9 @@ pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32] let my_uid = unsafe { libc::getuid() }; let my_pid = std::process::id() as i32; - let mut pids: Vec; - loop { - let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) }; - if count <= 0 { - return; - } - let buf_len = (count as usize) * 2; - pids = vec![0; buf_len]; - let actual = unsafe { - proc_listallpids( - pids.as_mut_ptr(), - (buf_len * std::mem::size_of::()) as libc::c_int, - ) - }; - if actual <= 0 { - return; - } - pids.truncate(actual as usize); - if (actual as usize) < buf_len { - break; - } + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return; } // Collect (pid, instance_id) for all foreign agent processes. @@ -1311,6 +1107,8 @@ pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32] #[cfg(not(unix))] pub(crate) fn reap_dead_instance_agents(_our_instance_id: &str, _skip_pids: &[u32]) {} +// Exact-path harness sweep lives in runtime/sweep.rs (re-exported above). + /// Kill stale agent processes from a previous session whose PID is still alive /// but not tracked in the current `runtimes` map. Updates the record fields and /// returns `true` if any records were modified. @@ -1357,6 +1155,7 @@ pub fn sync_managed_agent_processes( if let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) { record.updated_at = now_iso(); record.last_error = Some(format!("failed to inspect process state: {error}")); + record.last_error_code = None; } changed = true; exited.push(pubkey.clone()); @@ -1373,13 +1172,20 @@ pub fn sync_managed_agent_processes( record.runtime_pid = None; record.last_stopped_at = Some(now_iso()); record.last_exit_code = status.code(); - record.last_error = if status.success() { + let log_err = if status.success() { None } else { - super::meaningful_agent_error_from_log(&runtime.log_path) - .unwrap_or_else(|| format!("harness exited with status {status}")) - .into() + Some( + super::meaningful_agent_error_from_log(&runtime.log_path).unwrap_or_else( + || super::storage::AgentLogError { + message: format!("harness exited with status {status}"), + code: None, + }, + ), + ) }; + record.last_error = log_err.as_ref().map(|e| e.message.clone()); + record.last_error_code = log_err.as_ref().and_then(|e| e.code); } changed = true; @@ -1430,7 +1236,7 @@ pub fn sync_managed_agent_processes( /// - no persona_id: neither — a hand-built agent has no persona to drift from. fn persona_drift_state( record: &ManagedAgentRecord, - personas: &[crate::managed_agents::types::PersonaRecord], + personas: &[crate::managed_agents::types::AgentDefinition], ) -> (bool, bool) { let Some(persona_id) = record.persona_id.as_deref() else { return (false, false); @@ -1452,7 +1258,7 @@ pub fn build_managed_agent_summary( app: &AppHandle, record: &ManagedAgentRecord, runtimes: &HashMap, - personas: &[crate::managed_agents::types::PersonaRecord], + personas: &[crate::managed_agents::types::AgentDefinition], ) -> Result { use crate::managed_agents::BackendKind; @@ -1511,13 +1317,40 @@ pub fn build_managed_agent_summary( // next to an "out of date" badge would contradict it. let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas); + // Restart badge: the running process stamped its effective spawn config + // at launch; recompute from current disk state and flag drift. Only a + // tracked live process can drift — stopped agents spawn fresh, and + // adopted (runtime_pid-only) processes have no stamped hash to compare. + // + // Additionally, for runtimes with an adapter version gate (codex only), + // check whether the cached adapter availability has drifted from the value + // stamped at spawn. This catches out-of-band adapter changes (manual + // npm install/downgrade) that Phase-1 auto-restart doesn't cover. The + // cache is read-only here — no subprocess is spawned. + let needs_restart = runtimes.get(&record.pubkey).is_some_and(|runtime| { + use tauri::Manager; + let state = app.state::(); + let global_for_hash = + crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let teams_for_hash = crate::managed_agents::load_teams(app).unwrap_or_default(); + let hash_drift = runtime.spawn_config_hash + != crate::managed_agents::spawn_hash::spawn_config_hash( + record, + personas, + &teams_for_hash, + &crate::relay::relay_ws_url_with_override(&state), + &global_for_hash, + ); + let availability_drift = super::availability_drift( + runtime.adapter_availability.as_ref(), + super::adapter_availability_cached(), + ); + hash_drift || availability_drift + }); + // Resolve the effective harness the same way, then derive args/mcp from it, // so the UI reflects the persona's current harness (or an explicit pin). - let effective_command = crate::managed_agents::effective_agent_command( - record.persona_id.as_deref(), - personas, - record.agent_command_override.as_deref(), - ); + let effective_command = crate::managed_agents::record_agent_command(record, personas); let effective_args = normalize_agent_args(&effective_command, record.agent_args.clone()); let effective_mcp_command = known_acp_runtime(&effective_command) .and_then(|r| r.mcp_command) @@ -1544,7 +1377,7 @@ pub fn build_managed_agent_summary( provider: record.provider.clone(), persona_out_of_date, persona_orphaned, - mcp_toolsets: record.mcp_toolsets.clone(), + needs_restart, env_vars: record.env_vars.clone(), backend: record.backend.clone(), backend_agent_id: record.backend_agent_id.clone(), @@ -1556,7 +1389,9 @@ pub fn build_managed_agent_summary( last_stopped_at: record.last_stopped_at.clone(), last_exit_code: record.last_exit_code, last_error: record.last_error.clone(), + last_error_code: record.last_error_code, start_on_app_launch: record.start_on_app_launch, + auto_restart_on_config_change: record.auto_restart_on_config_change, log_path, respond_to: record.respond_to, respond_to_allowlist: record.respond_to_allowlist.clone(), @@ -1665,11 +1500,11 @@ pub fn spawn_agent_child( // command, so we recompute them from the effective value rather than the // frozen record snapshot. Mirrors the model resolution below. let personas = super::load_personas(app).unwrap_or_default(); - let effective_command = super::effective_agent_command( - record.persona_id.as_deref(), - &personas, - record.agent_command_override.as_deref(), - ); + let teams = super::load_teams(app).unwrap_or_default(); + // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) + // and for the env-var merge at spawn time. + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let effective_command = super::record_agent_command(record, &personas); let agent_args = normalize_agent_args(&effective_command, record.agent_args.clone()); let resolved_acp_command = resolve_command(&record.acp_command) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; @@ -1708,14 +1543,19 @@ pub fn spawn_agent_child( // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink + // - nvm-managed node/npm (nvm initializes only in interactive shells) // - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/) // - runtimes (node, python, etc.) via login shell PATH + let nvm_bin = dirs::home_dir() + .as_deref() + .and_then(super::find_nvm_default_bin); let augmented_path = build_augmented_path( dirs::home_dir(), std::env::current_exe() .ok() .and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)), login_shell_path(), + nvm_bin, ); let mut command = std::process::Command::new(&resolved_acp_command); @@ -1747,6 +1587,114 @@ pub fn spawn_agent_child( if runtime_meta.is_some_and(|r| r.mcp_hooks) { command.env("MCP_HOOK_SERVERS", "*"); } + + // ── Readiness check: set setup-payload if agent is not ready ───────────── + // + // Build the effective env the agent would have at start-time, run the + // readiness predicate, and if anything is missing, serialize the payload + // into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp detects this env var on startup + // and enters the minimal setup-listener mode instead of the agent pool. + // + // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS so user env + // cannot set it, but we also explicitly remove it after writing user env + // to guard against the parent-process environment. We then set it only + // when desktop has computed NotReady — the desktop is the sole readiness + // source and buzz-acp only transports the payload. + // + // The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: + // { "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] } + // + // `spawned_setup_mode` is captured outside the block so it can be stamped + // on `ManagedAgentProcess` — used by `install_acp_runtime` to target only + // stuck agents for auto-restart. + let spawned_setup_mode; + { + use crate::managed_agents::{ + agent_readiness, resolve_effective_agent_env, AgentReadiness, Requirement, + }; + + let effective = resolve_effective_agent_env(record, &personas, runtime_meta, &global); + // Compute the optional payload before touching the command. + let setup_payload_json = + if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { + let reqs: Vec = requirements + .into_iter() + .map(|r| match r { + Requirement::NormalizedField { field } => serde_json::json!({ + "surface": "normalized_field", + "field": field, + }), + Requirement::EnvKey { key } => serde_json::json!({ + "surface": "env_key", + "key": key, + }), + Requirement::CliLogin { + probe_args, + setup_copy, + availability, + } => serde_json::json!({ + "surface": "cli_login", + "probe_args": probe_args, + "setup_copy": setup_copy, + "availability": availability, + }), + Requirement::CliConfigInvalid { + probe_args, + setup_copy, + diagnostic, + } => serde_json::json!({ + "surface": "cli_config_invalid", + "probe_args": probe_args, + "setup_copy": setup_copy, + "diagnostic": diagnostic, + }), + Requirement::GitBash => serde_json::json!({ + "surface": "git_bash", + }), + }) + .collect(); + let payload = serde_json::json!({ + "agent_name": record.name, + "agent_pubkey": record.pubkey, + "requirements": reqs, + }); + match serde_json::to_string(&payload) { + Ok(json) => Some(json), + Err(e) => { + eprintln!( + "buzz-desktop: failed to serialize setup payload for {}: {e}", + record.name + ); + None + } + } + } else { + None + }; + + spawned_setup_mode = setup_payload_json.is_some(); + + // Strip the key from the process-spawned command on every path. + // Two independent guards protect the invariant: + // 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so + // merged_user_env() can never write it via saved/persona env. + // 2. This env_remove() clears any ambient parent-process value + // inherited by std::process::Command before we conditionally + // set the desktop-computed trusted value below. + // Note: merged_user_env() is written further below in this function; + // ordering relative to that call is NOT what makes this safe — the + // reserved-key strip (guard 1) handles user env regardless of order. + command.env_remove("BUZZ_ACP_SETUP_PAYLOAD"); + + // Set the payload only when desktop computed NotReady. + if let Some(json) = setup_payload_json { + command.env("BUZZ_ACP_SETUP_PAYLOAD", json); + eprintln!( + "buzz-desktop: agent {} not ready — spawning in setup-listener mode", + record.name + ); + } + } // Only emit BUZZ_ACP_IDLE_TIMEOUT when the user has explicitly set an // override. When unset, the buzz-acp harness applies its own default // (see `DEFAULT_IDLE_TIMEOUT_SECS` in crates/buzz-acp/src/config.rs), @@ -1771,30 +1719,29 @@ pub fn spawn_agent_child( } } } - if let (Some(team_dir), Some(persona_name)) = - (&record.persona_team_dir, &record.persona_name_in_team) - { - command.env("BUZZ_ACP_PERSONA_PACK", team_dir); - command.env("BUZZ_ACP_PERSONA_NAME", persona_name); + let team_instructions = super::spawn_hash::effective_team_instructions(record, &teams); + if let Some(instructions) = &team_instructions { + command.env("BUZZ_ACP_TEAM_INSTRUCTIONS", instructions); + } else { + command.env_remove("BUZZ_ACP_TEAM_INSTRUCTIONS"); } - // System prompt, model, and provider come from the record snapshot — the - // record is the authoritative spawn source. For persona-created agents the - // snapshot was pinned at create (see `create_managed_agent`); for others - // these are the user-supplied values. Reading the record (never the live - // persona) is what keeps a running agent pinned across restarts: a persona - // edit reaches the agent only via delete+respawn, which rewrites the - // snapshot. - let effective_prompt = record.system_prompt.clone(); - let effective_model = record.model.clone(); - let effective_provider = record.provider.clone(); + // System prompt via the shared spawn-effective filter — the SAME function the + // config hash digests, so env write and badge cannot disagree (see + // `effective_spawn_prompt` for the Some("")/None collapse). Model and provider + // use the shared + // resolver: agent → persona → global → None, so a global-default-only agent + // spawns with the correct provider/model env. + let effective_prompt = super::spawn_hash::effective_spawn_prompt(record); + let (effective_model, effective_provider) = + crate::managed_agents::resolve_effective_model_provider(record, &personas, &global); if let Some(prompt) = &effective_prompt { command.env("BUZZ_ACP_SYSTEM_PROMPT", prompt); } else { command.env_remove("BUZZ_ACP_SYSTEM_PROMPT"); } - if let Some(model) = &effective_model { + if let Some(model) = effective_model { command.env("BUZZ_ACP_MODEL", model); } else { command.env_remove("BUZZ_ACP_MODEL"); @@ -1808,17 +1755,12 @@ pub fn spawn_agent_child( meta.model_env_var, meta.provider_env_var, meta.provider_locked, - effective_model.as_deref(), - effective_provider.as_deref(), + effective_model, + effective_provider, ) { command.env(key, value); } } - if let Some(toolsets) = &record.mcp_toolsets { - command.env("BUZZ_TOOLSETS", toolsets); - } else { - command.env("BUZZ_TOOLSETS", "default,canvas,forums,dms,media"); - } command.env_remove("BUZZ_ACP_PRIVATE_KEY"); command.env_remove("BUZZ_ACP_API_TOKEN"); command.env_remove("BUZZ_API_TOKEN"); @@ -1877,27 +1819,43 @@ pub fn spawn_agent_child( ); } - // ── User env vars: the record snapshot ───────────────────────────── + // ── User env vars: live persona env under agent overrides ────────── // - // The record's `env_vars` is the complete, pinned env map — persona env - // (snapshotted at create) already merged under the agent's own overrides. - // We read it directly and never look up the live persona, so credential - // edits on the persona reach the agent only via delete+respawn (which - // rewrites the snapshot), not on a plain restart. `merged_user_env` with an - // empty persona map still applies the reserved-key / malformed-key / NUL - // filtering as defense-in-depth for older on-disk records. + // The record's `env_vars` holds agent-level overrides only. The linked + // persona's env is read live and merged underneath (agent wins on + // collision), so persona credential edits reach the agent on the next + // spawn — same refresh semantics as prompt/model/provider above and the + // provider deploy path. Global env vars are the floor layer below persona. + // `merged_user_env` also applies the reserved-key / malformed-key / NUL + // filtering. Precedence: baked floor < Buzz-set env above < GLOBAL < + // PERSONA < per-agent. // // These writes go LAST so user-provided values win over every Buzz-set env // above — EXCEPT reserved keys (BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, // BUZZ_AUTH_TAG, BUZZ_API_TOKEN, BUZZ_ACP_PRIVATE_KEY, BUZZ_ACP_API_TOKEN), // which `merged_user_env` strips. Those carry Buzz's identity and must // never be GUI-overridable. - for (key, value) in - super::env_vars::merged_user_env(&std::collections::BTreeMap::new(), &record.env_vars) - { + // global < live persona < agent (last-wins on collision at each layer). + let persona_over_global = super::env_vars::merged_user_env( + &global.env_vars, + &super::env_vars::live_persona_env(&personas, record.persona_id.as_deref()), + ); + for (key, value) in super::env_vars::merged_user_env(&persona_over_global, &record.env_vars) { command.env(key, value); } + // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible + // transport at spawn time and scrub any unrelated ambient OpenAI key. + #[cfg(feature = "mesh-llm")] + if effective_provider == Some(super::RELAY_MESH_PROVIDER_ID) { + let mut mesh_env = std::collections::BTreeMap::new(); + super::apply_relay_mesh_env(&mut mesh_env, effective_provider, effective_model); + command.env_remove("OPENAI_API_KEY"); + for (key, value) in mesh_env { + command.env(key, value); + } + } + // Mark as Buzz-managed *and* which desktop instance owns us, so the // system-wide orphan sweep only reaps this instance's own agents and never // another live Buzz's (e.g. a `just dev` build won't kill a DMG build's @@ -1931,6 +1889,32 @@ pub fn spawn_agent_child( ) })?; + // Stamp the effective spawn config so the summary builder can flag + // needs_restart when disk state drifts from what this process runs. + // `effective_relay_url` is already resolved, and resolution is idempotent, + // so it serves as the workspace-relay input here. + let spawn_config_hash = super::spawn_hash::spawn_config_hash( + record, + &personas, + &teams, + &effective_relay_url, + &global, + ); + + // Stamp the adapter availability for runtimes with a version gate (codex + // only). The summary builder compares this against the current cached value + // to detect out-of-band adapter changes after spawn (Phase-2 badge fallback). + // Non-codex runtimes get `None` — nothing changes for them. + // When the cache is cold (e.g. Doctor just installed and cleared the cache), + // `adapter_availability_cached()` returns `None`, so the stamp is `None` and + // the drift check is skipped until discovery warms the cache — preventing a + // false restart badge immediately after auto-restart. + let spawned_adapter_availability = if runtime_meta.is_some_and(|r| r.id == "codex") { + super::adapter_availability_cached() + } else { + None + }; + let _ = super::write_agent_pid_file(app, &record.pubkey, child.id()); // Windows: assign the harness to a Job Object so its whole tree dies with @@ -1939,10 +1923,19 @@ pub fn spawn_agent_child( return Ok(super::process_lifecycle::finish_spawn( child, log_path, + spawn_config_hash, + spawned_setup_mode, + spawned_adapter_availability, &record.name, )); #[cfg(not(windows))] - Ok(crate::managed_agents::ManagedAgentProcess { child, log_path }) + Ok(crate::managed_agents::ManagedAgentProcess { + child, + log_path, + spawn_config_hash, + setup_mode: spawned_setup_mode, + adapter_availability: spawned_adapter_availability, + }) } fn child_rust_log_filter() -> String { @@ -1979,6 +1972,7 @@ pub fn start_managed_agent_process( { record.updated_at = now_iso(); record.last_error = None; + record.last_error_code = None; return Ok(()); } @@ -1994,6 +1988,7 @@ pub fn start_managed_agent_process( record.last_stopped_at = None; record.last_exit_code = None; record.last_error = None; + record.last_error_code = None; runtimes.insert(record.pubkey.clone(), process); Ok(()) @@ -2016,6 +2011,7 @@ pub fn stop_managed_agent_process( record.last_stopped_at = Some(now); record.last_exit_code = None; record.last_error = None; + record.last_error_code = None; } super::remove_agent_pid_file(app, &record.pubkey); return Ok(()); @@ -2050,6 +2046,7 @@ pub fn stop_managed_agent_process( record.last_stopped_at = Some(now); record.last_exit_code = status.code(); record.last_error = None; + record.last_error_code = None; super::remove_agent_pid_file(app, &record.pubkey); @@ -2095,25 +2092,30 @@ pub(crate) fn runtime_metadata_env_vars<'a>( /// Resolve the effective (prompt, model, provider) triple for a persona-linked agent. /// /// Given a persona_id, finds the persona in the list and returns its system_prompt, -/// model, and provider as the authoritative values. Falls back to the record's own -/// prompt/model and None for provider when no persona is linked or found. +/// model, and provider as the authoritative values. When the persona leaves `model` +/// or `provider` blank (None or whitespace-only), falls back to the record's own +/// field using the same precedence rule as `persona_snapshot_with_agent_config_fallback` +/// so the display surface matches spawn behavior. Falls back to the record's own +/// prompt/model/provider when no persona is linked or found. /// /// Used by `agent_config.rs` to inject persona defaults into the config surface /// before running the reader, so BuzzExplicit-tagged fields can be re-tagged to /// PersonaDefault for fields the record did not independently set. pub(crate) fn resolve_effective_prompt_model_provider( persona_id: Option<&str>, - personas: &[crate::managed_agents::types::PersonaRecord], + personas: &[crate::managed_agents::types::AgentDefinition], record_prompt: Option, record_model: Option, + record_provider: Option, ) -> (Option, Option, Option) { + let fallback = crate::managed_agents::persona_events::persona_field_with_record_fallback; match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { Some(p) => ( Some(p.system_prompt.clone()), - p.model.clone(), - p.provider.clone(), + fallback(p.model.as_deref(), record_model.as_deref()), // fallback: record.model + fallback(p.provider.as_deref(), record_provider.as_deref()), // fallback: record.provider ), - None => (record_prompt, record_model, None), + None => (record_prompt, record_model, record_provider), } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/path.rs b/desktop/src-tauri/src/managed_agents/runtime/path.rs index 0185e61b82..af2d2e96da 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/path.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/path.rs @@ -4,10 +4,11 @@ use std::path::PathBuf; /// Assemble the augmented `PATH` for a launched managed-agent child process. /// -/// Concatenates, in priority order: `/.local/bin` (the bundled CLI -/// symlink), the running executable's parent dir (DMG sidecars under -/// `Contents/MacOS/`), and the user's login-shell `PATH` (runtimes like -/// node/python). +/// Concatenates, in priority order: +/// 1. `/.local/bin` — bundled CLI symlink +/// 2. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) +/// 3. exe parent dir — DMG sidecars under `Contents/MacOS/` +/// 4. user's login-shell `PATH` — runtimes like node/python from other managers /// /// `shell_path` is the raw colon-delimited string from a login shell, so it is /// split into individual entries before joining. Pushing it as a single segment @@ -15,15 +16,19 @@ use std::path::PathBuf; /// error), collapsing the entire augmented `PATH` to `None` — the bug this /// guards against, which left managed agents unable to find `buzz`. Returns /// `None` only when no entries exist. -pub(super) fn build_augmented_path( +pub(in crate::managed_agents) fn build_augmented_path( home: Option, exe_parent: Option, shell_path: Option, + nvm_bin: Option, ) -> Option { let mut parts: Vec = Vec::new(); if let Some(home) = home { parts.push(home.join(".local").join("bin")); } + if let Some(nvm_bin) = nvm_bin { + parts.push(nvm_bin); + } if let Some(parent) = exe_parent { parts.push(parent); } @@ -55,6 +60,7 @@ mod tests { Some(PathBuf::from("/home/agent")), Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")), Some("/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin".to_string()), + None, ); assert_eq!( result.as_deref(), @@ -67,13 +73,48 @@ mod tests { #[test] fn none_when_no_inputs() { - assert_eq!(build_augmented_path(None, None, None), None); + assert_eq!(build_augmented_path(None, None, None, None), None); } #[cfg(unix)] #[test] fn shell_path_only() { - let result = build_augmented_path(None, None, Some("/usr/bin:/bin".to_string())); + let result = build_augmented_path(None, None, Some("/usr/bin:/bin".to_string()), None); assert_eq!(result.as_deref(), Some("/usr/bin:/bin")); } + + #[cfg(unix)] + #[test] + fn nvm_bin_inserted_after_local_bin_before_exe_parent() { + let result = build_augmented_path( + Some(PathBuf::from("/home/user")), + Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")), + Some("/usr/bin:/bin".to_string()), + Some(PathBuf::from("/home/user/.nvm/versions/node/v20.0.0/bin")), + ); + assert_eq!( + result.as_deref(), + Some( + "/home/user/.local/bin:\ +/home/user/.nvm/versions/node/v20.0.0/bin:\ +/Applications/Buzz.app/Contents/MacOS:\ +/usr/bin:/bin" + ), + ); + } + + #[cfg(unix)] + #[test] + fn nvm_bin_none_does_not_add_segment() { + let result = build_augmented_path( + Some(PathBuf::from("/home/user")), + Some(PathBuf::from("/usr/local/bin")), + None, + None, + ); + assert_eq!( + result.as_deref(), + Some("/home/user/.local/bin:/usr/local/bin"), + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/sweep.rs b/desktop/src-tauri/src/managed_agents/runtime/sweep.rs new file mode 100644 index 0000000000..3060ff6593 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/sweep.rs @@ -0,0 +1,721 @@ +//! Boot-time sweep for untracked same-bundle harness processes, plus low-level +//! process-tree helpers shared with the periodic orphan sweeps in `runtime.rs`. +//! +//! The env-var and PID-file sweeps cannot see a harness whose receipt is gone +//! or that predates `BUZZ_MANAGED_AGENT` injection. This sweep derives the +//! expected `buzz-acp` path from the running executable and kills any process +//! whose exe matches exactly, minus the tracked set. The PID enumeration, +//! procargs, parent/PGID lookups, and live-descendant classification helpers +//! collected here are also called directly by the periodic orphan sweeps. + +use std::path::{Path, PathBuf}; + +// Re-declare the macOS process-info FFI so sweep.rs can call it independently. +// Multiple extern "C" declarations of the same symbol are legal in Rust; the +// linker sees one symbol regardless of how many translation units declare it. +#[cfg(target_os = "macos")] +extern "C" { + fn proc_listallpids(buffer: *mut libc::c_int, buffersize: libc::c_int) -> libc::c_int; + fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; +} + +// ── Shared low-level helpers ────────────────────────────────────────────── + +/// Collect all PIDs currently on the system. +/// +/// Loops until the buffer is large enough to hold all PIDs — under a fork +/// storm the count can grow between the probe and the fill call. Returns an +/// empty vec on any kernel error. +#[cfg(target_os = "macos")] +pub(super) fn collect_all_pids() -> Vec { + let mut pids: Vec; + loop { + let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) }; + if count <= 0 { + return Vec::new(); + } + let buf_len = (count as usize) * 2; + pids = vec![0; buf_len]; + let actual = unsafe { + proc_listallpids( + pids.as_mut_ptr(), + (buf_len * std::mem::size_of::()) as libc::c_int, + ) + }; + if actual <= 0 { + return Vec::new(); + } + pids.truncate(actual as usize); + if (actual as usize) < buf_len { + return pids; + } + } +} + +/// Read the raw `KERN_PROCARGS2` buffer for the given PID. +/// +/// Two-phase sysctl: first probe for the required buffer size, then fill. +/// A process's arguments are immutable after `execve`, so the size reported +/// by the probe is stable — no grow-and-retry loop is needed. +/// +/// Returns `None` if either sysctl call fails (e.g. the process has already +/// exited or we lack permission). +#[cfg(target_os = "macos")] +pub(super) fn procargs2_buffer(pid: u32) -> Option> { + let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int]; + let mut buf_size: libc::size_t = 0; + + if unsafe { + libc::sysctl( + mib.as_mut_ptr(), + 3, + std::ptr::null_mut(), + &mut buf_size, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + return None; + } + + let mut buf: Vec = vec![0; buf_size]; + if unsafe { + libc::sysctl( + mib.as_mut_ptr(), + 3, + buf.as_mut_ptr() as *mut libc::c_void, + &mut buf_size, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + return None; + } + buf.truncate(buf_size); + Some(buf) +} + +// ── Ancestor walk ──────────────────────────────────────────────────────── + +/// True if walking `start`'s parent chain reaches any PID in `skip_pids`. +/// Bounded to 32 hops to guard against PPID cycles from PID reuse; a lookup +/// failure or reaching PID ≤ 1 ends the walk (process is not a descendant of +/// any tracked harness). +/// +/// The candidate itself being in `skip_pids` is handled at the call site — +/// this function checks strict ancestors only. +#[cfg(unix)] +pub(super) fn walk_has_tracked_ancestor( + start: u32, + skip_pids: &[u32], + parent_of: impl Fn(u32) -> Option, +) -> bool { + const MAX_DEPTH: usize = 32; + let mut cur = start; + for _ in 0..MAX_DEPTH { + let Some(parent) = parent_of(cur) else { + return false; + }; + if parent <= 1 || parent == cur { + return false; + } + if skip_pids.contains(&parent) { + return true; + } + cur = parent; + } + false +} + +/// OS-resolved parent-PID lookup for `walk_has_tracked_ancestor`. +/// Test-only: lets tests call a single platform-agnostic name without +/// cfg gates; production code calls `ppid_of_macos`/`ppid_of_linux` directly. +#[cfg(all(test, target_os = "macos"))] +pub(super) fn ppid_of(pid: u32) -> Option { + ppid_of_macos(pid) +} + +/// OS-resolved parent-PID lookup for `walk_has_tracked_ancestor`. +/// Test-only: lets tests call a single platform-agnostic name without +/// cfg gates; production code calls `ppid_of_macos`/`ppid_of_linux` directly. +#[cfg(all(test, unix, not(target_os = "macos")))] +pub(super) fn ppid_of(pid: u32) -> Option { + ppid_of_linux(pid) +} + +/// Return the parent PID of a process on macOS via `proc_pidinfo`. +/// Returns `None` if the syscall fails (process may have exited). +#[cfg(target_os = "macos")] +pub(super) fn ppid_of_macos(pid: u32) -> Option { + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + super::proc_pidinfo( + pid as libc::c_int, + super::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + return None; + } + Some(unsafe { info.assume_init() }.pbi_ppid) +} + +/// Parse the PPID and PGID fields from `/proc//stat` in one read. +/// Fields after the last `)` (comm may contain spaces/parens): index 1 is +/// PPID, index 2 is PGID. +#[cfg(all(unix, not(target_os = "macos")))] +pub(super) fn proc_stat_ppid_pgid_linux(pid: u32) -> Option<(u32, u32)> { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let after_comm = stat.rsplit_once(')')?.1; + // Fields after ')': " S ppid pgid ..." + let mut fields = after_comm.split_whitespace(); + let _state = fields.next()?; // index 0: state + let ppid = fields.next()?.parse::().ok()?; // index 1: PPID + let pgid = fields.next()?.parse::().ok()?; // index 2: PGID + Some((ppid, pgid)) +} + +/// Return the parent PID of a process from `/proc//stat`. +#[cfg(all(unix, not(target_os = "macos")))] +pub(super) fn ppid_of_linux(pid: u32) -> Option { + proc_stat_ppid_pgid_linux(pid).map(|(ppid, _)| ppid) +} + +/// True if `pid` is a live descendant of any tracked harness in `skip_pids`. +/// +/// Three complementary checks: +/// 1. Direct parent — `ppid` was already fetched by the caller's BSDInfo +/// UID gate, so this hop is free. +/// 2. Reparenting guard — if an intermediate in the ancestor chain died, +/// the process reparents to init (PPID 1) and the ancestor walk can no +/// longer reach the harness; a process that started inside the +/// harness's process group still has PGID == harness PID, so the PGID +/// check spares it. This is NOT a redundant fast-path — it covers a +/// case the walk cannot. +/// 3. Bounded ancestor walk from `ppid` — covers deeper live chains where +/// intermediates run in their own process groups (e.g. buzz-acp -> +/// node shim -> codex-acp). +#[cfg(target_os = "macos")] +pub(super) fn is_live_descendant_macos(pid: u32, ppid: u32, skip_pids: &[u32]) -> bool { + if skip_pids.contains(&ppid) { + return true; + } + let pgid = unsafe { libc::getpgid(pid as i32) }; + if pgid > 0 && skip_pids.contains(&(pgid as u32)) { + return true; + } + walk_has_tracked_ancestor(ppid, skip_pids, ppid_of_macos) +} + +/// Linux variant: reads PPID and PGID from `/proc//stat` in a single +/// read, then applies the same three checks as the macOS variant. An +/// unreadable stat file (process exiting) yields `false` — the two-tick +/// grace in the periodic sweep absorbs transient failures. +#[cfg(all(unix, not(target_os = "macos")))] +pub(super) fn is_live_descendant_linux(pid: u32, skip_pids: &[u32]) -> bool { + let Some((ppid, pgid)) = proc_stat_ppid_pgid_linux(pid) else { + return false; + }; + if skip_pids.contains(&ppid) || skip_pids.contains(&pgid) { + return true; + } + walk_has_tracked_ancestor(ppid, skip_pids, ppid_of_linux) +} + +// ── ProcessSnapshot and pure decision function ──────────────────────────── + +/// A snapshot of one process for the pure kill-decision function. Holds only +/// the fields needed to decide whether a process is an untracked same-bundle +/// harness — no live process handles, no system calls. +#[derive(Debug, Clone)] +pub struct ProcessSnapshot { + /// PID of the process. + pub pid: u32, + /// Full executable path, as reported by the kernel. + pub exe_path: PathBuf, +} + +/// Strip the kernel-appended `" (deleted)"` suffix from an executable path. +/// +/// On Linux, `read_link("/proc//exe")` returns `…/buzz-acp (deleted)` +/// when the on-disk binary has been replaced since the process launched. +/// That is exactly the class of stale-install orphan this sweep targets, so +/// we must strip the suffix before comparing against the expected path. +/// Falls back to the original path unchanged when the suffix is absent. +// Available on Linux (where it is called from collect_process_snapshots) and +// in test builds on all platforms (including macOS). Dead on macOS outside +// tests — suppress the warning there. +#[cfg(any(all(unix, not(target_os = "macos")), test))] +pub(super) fn strip_deleted_suffix(path: PathBuf) -> PathBuf { + const SUFFIX: &str = " (deleted)"; + match path.to_str() { + Some(s) if s.ends_with(SUFFIX) => PathBuf::from(&s[..s.len() - SUFFIX.len()]), + _ => path, + } +} + +/// Pure kill-decision function: given a slice of process snapshots, the +/// expected harness executable path, and the set of tracked pids to spare, +/// returns the pids of processes that should be reaped. +/// +/// Selection criteria: +/// - `exe_path` exactly matches `harness_exe` (same-bundle harness only). +/// - `pid` is not in `tracked_pids` (untracked — not owned by this session). +/// +/// Children of tracked parents die when their parent's process group is +/// signalled — this function deliberately targets only harness-level processes +/// so we never directly kill a child of a live tracked parent. +pub fn select_untracked_bundle_harnesses( + snapshots: &[ProcessSnapshot], + harness_exe: &Path, + tracked_pids: &[u32], +) -> Vec { + snapshots + .iter() + .filter(|s| s.exe_path == harness_exe && !tracked_pids.contains(&s.pid)) + .map(|s| s.pid) + .collect() +} + +// ── Process-table enumeration ───────────────────────────────────────────── + +/// Extract the executable path from a process's `KERN_PROCARGS2` buffer. +/// +/// The buffer layout is: `[i32 argc][exec_path\0][null-pad][argv\0…][env\0…]`. +/// The exec path is therefore the first null-terminated string immediately +/// after the leading `i32` — no argv traversal is needed, unlike +/// `extract_buzz_marker_value` / `process_has_buzz_marker` which must skip +/// past both argv and the exec path to reach the environment entries. +/// +/// Returns `None` if the buffer is unreadable or malformed. +#[cfg(target_os = "macos")] +fn proc_exe_path_from_procargs2(pid: u32) -> Option { + let buf = procargs2_buffer(pid)?; + if buf.len() < std::mem::size_of::() { + return None; + } + // Skip the argc i32 at the start of the buffer. + let pos = std::mem::size_of::(); + // The exec path immediately follows — scan to the first null byte. + let end = buf[pos..].iter().position(|&b| b == 0).map(|i| pos + i)?; + let path_bytes = &buf[pos..end]; + if path_bytes.is_empty() { + return None; + } + // KERN_PROCARGS2 exec paths are always absolute UTF-8 on macOS. + let s = std::str::from_utf8(path_bytes).ok()?; + Some(PathBuf::from(s)) +} + +/// Collect process snapshots for all user-owned processes on macOS. +/// +/// Applies a cheap `proc_name` pre-filter before the expensive +/// `KERN_PROCARGS2` sysctl: only PIDs whose binary name matches the harness +/// binary name (`buzz-acp`) proceed to the full exe-path fetch. This mirrors +/// the pattern `sweep_system_agent_processes` uses and cuts the expensive +/// two-sysctl call by ~99.9% (there are typically O(hundreds) of user +/// processes but at most a handful of `buzz-acp` instances). +#[cfg(target_os = "macos")] +fn collect_process_snapshots(harness_name: &str) -> Vec { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut snapshots = Vec::new(); + + let pids = collect_all_pids(); + + for &pid in &pids { + if pid <= 0 || pid == my_pid { + continue; + } + // Cheap name pre-filter: only proceed to the expensive KERN_PROCARGS2 + // sysctl for PIDs whose binary name matches the harness binary name. + let mut name_buf = [0u8; 1024]; + let len = unsafe { + proc_name( + pid, + name_buf.as_mut_ptr() as *mut libc::c_void, + name_buf.len() as u32, + ) + }; + if len <= 0 { + continue; + } + let name = String::from_utf8_lossy(&name_buf[..len as usize]); + if name != harness_name { + continue; + } + // Verify UID to avoid inspecting processes owned by other users. + let upid = pid as u32; + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + super::proc_pidinfo( + pid, + super::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + if let Some(exe_path) = proc_exe_path_from_procargs2(upid) { + snapshots.push(ProcessSnapshot { + pid: upid, + exe_path, + }); + } + } + snapshots +} + +/// Collect process snapshots for all user-owned processes on Linux via /proc. +/// +/// On Linux `/proc//exe` is a symlink to the executable path. The kernel +/// appends `" (deleted)"` when the on-disk binary has been replaced since +/// launch — exactly the stale-install class this sweep targets. The suffix is +/// stripped so the comparison against `expected_harness_exe_path` succeeds. +#[cfg(all(unix, not(target_os = "macos")))] +fn collect_process_snapshots(harness_name: &str) -> Vec { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut snapshots = Vec::new(); + + let Ok(entries) = std::fs::read_dir("/proc") else { + return snapshots; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + // Cheap name pre-filter via /proc//comm (15-char truncated, but + // "buzz-acp" is 8 chars so it's always preserved). + let Ok(comm) = std::fs::read_to_string(format!("/proc/{upid}/comm")) else { + continue; + }; + if comm.trim() != harness_name { + continue; + } + // Check ownership. + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + // Resolve the executable path via the /proc symlink. + // Strip " (deleted)" so orphans whose binary was replaced still match. + if let Ok(exe_path) = std::fs::read_link(format!("/proc/{upid}/exe")) { + snapshots.push(ProcessSnapshot { + pid: upid, + exe_path: strip_deleted_suffix(exe_path), + }); + } + } + snapshots +} + +// ── expected_harness_exe_path ───────────────────────────────────────────── + +/// Derive the expected path of the `buzz-acp` harness binary next to the +/// current executable. Returns `None` if `current_exe()` fails or has no +/// parent directory. +/// +/// In a `.app` bundle: `.../Contents/MacOS/buzz-acp`. +/// In a dev checkout: `/debug/buzz-acp` or similar. +/// Never hardcoded — always derived from the running process. +/// +/// Attempts `std::fs::canonicalize` to resolve symlinks so the path +/// comparison in `select_untracked_bundle_harnesses` is stable even when the +/// bundle is accessed through a symlink. Falls back to the raw (unresolved) +/// path on `canonicalize` failure — canonicalization can fail for paths that +/// exist only as kernel metadata (e.g. a process launched from a path that +/// has since been moved), so failure must never prevent the sweep from running; +/// it only narrows the comparison to raw paths, which is still correct for +/// the common case. +/// +/// Residual false-negative: if the bundle itself has been translocated or +/// moved since launch, `current_exe()` reflects the new path but a running +/// harness may report the old path. In that case the exe-path comparison +/// fails harmlessly — the orphan is not killed, but neither is anything +/// incorrectly killed. Similarly, an orphan spawned by an older install of +/// the same app (different bundle path, e.g. a prior DMG) will not match +/// this path — that class is handled by `sweep_system_agent_processes`, which +/// scopes by `BUZZ_MANAGED_AGENT` instance ID rather than exe path. +pub fn expected_harness_exe_path() -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + let raw = dir.join("buzz-acp"); + // Canonicalize if possible; fall back to the raw path on failure. + Some(std::fs::canonicalize(&raw).unwrap_or(raw)) +} + +/// The basename of the harness binary — used for the cheap name pre-filter in +/// `collect_process_snapshots` before the expensive exe-path lookup. +const HARNESS_BINARY_NAME: &str = "buzz-acp"; + +// ── sweep_untracked_bundle_harnesses ───────────────────────────────────── + +/// Sweep and kill harness processes that share this bundle's exact `buzz-acp` +/// executable path but are not in `skip_pids`. +/// +/// Complements the env-var-based `sweep_system_agent_processes`: this sweep +/// catches orphans that predate the `BUZZ_MANAGED_AGENT` env var injection +/// and any that lost their PID-file receipt. +/// +/// **Boot-time only.** This function is called once, under the store lock, +/// before Phase B spawns any new agents — there is no window for a legitimate +/// harness to have started between the tracked-pid snapshot and this scan, so +/// no grace mechanism is needed. A future periodic caller would need the +/// `sweep_system_agent_processes_with_grace`-style two-tick grace to avoid +/// killing a harness that is legitimately starting up between the skip-list +/// snapshot and the scan. +/// +/// Scoping guarantee: only processes whose exe path (after symlink resolution +/// where possible) equals `/buzz-acp` are candidates. Dev builds +/// at a different path, other installs, and children of tracked parents are +/// never directly targeted. Children die with their parent's process group +/// when `resolve_pgids_and_kill` signals the PGID. +#[cfg(unix)] +pub(crate) fn sweep_untracked_bundle_harnesses(skip_pids: &[u32]) { + let Some(harness_exe) = expected_harness_exe_path() else { + return; + }; + let snapshots = collect_process_snapshots(HARNESS_BINARY_NAME); + let to_kill = select_untracked_bundle_harnesses(&snapshots, &harness_exe, skip_pids); + if to_kill.is_empty() { + return; + } + eprintln!( + "buzz-desktop: sweep_untracked_bundle_harnesses: reaping {} stale harness process(es) {:?} (exe: {})", + to_kill.len(), + to_kill, + harness_exe.display(), + ); + // Small snapshot→kill PID-reuse window: a PID in `to_kill` could be + // recycled between the snapshot and the kill call. This matches the + // precedent set by the neighboring sweeps; `resolve_pgids_and_kill`'s + // PGID-recycling retain guard (skip a resolved PGID that is alive but + // not one of our orphan candidates) narrows the window further. + let to_kill_i32: Vec = to_kill.iter().map(|&p| p as i32).collect(); + super::resolve_pgids_and_kill(&to_kill_i32); +} + +#[cfg(not(unix))] +pub(crate) fn sweep_untracked_bundle_harnesses(_skip_pids: &[u32]) {} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── strip_deleted_suffix ───────────────────────────────────────────── + + #[test] + fn strip_deleted_suffix_removes_kernel_suffix() { + // Linux appends " (deleted)" when the binary has been replaced since + // launch — this is exactly the stale orphan class we want to reap. + let p = PathBuf::from("/Applications/Buzz.app/Contents/MacOS/buzz-acp (deleted)"); + assert_eq!( + strip_deleted_suffix(p), + PathBuf::from("/Applications/Buzz.app/Contents/MacOS/buzz-acp") + ); + } + + #[test] + fn strip_deleted_suffix_leaves_normal_path_unchanged() { + let p = PathBuf::from("/Applications/Buzz.app/Contents/MacOS/buzz-acp"); + assert_eq!(strip_deleted_suffix(p.clone()), p,); + } + + #[test] + fn strip_deleted_suffix_does_not_strip_partial_match() { + // "(deleted)" without the leading space must not be stripped. + let p = PathBuf::from("/some/path/buzz-acp(deleted)"); + assert_eq!(strip_deleted_suffix(p.clone()), p,); + } + + // ── select_untracked_bundle_harnesses ──────────────────────────────── + + const BUNDLE_HARNESS: &str = "/Applications/Buzz.app/Contents/MacOS/buzz-acp"; + const DEV_HARNESS: &str = "/Users/dev/buzz/.worktrees/main/target/debug/buzz-acp"; + + fn snap(pid: u32, path: &str) -> ProcessSnapshot { + ProcessSnapshot { + pid, + exe_path: PathBuf::from(path), + } + } + + #[test] + fn untracked_same_bundle_harness_is_killed() { + let snapshots = vec![snap(1001, BUNDLE_HARNESS)]; + let result = + select_untracked_bundle_harnesses(&snapshots, &PathBuf::from(BUNDLE_HARNESS), &[]); + assert_eq!(result, vec![1001]); + } + + #[test] + fn tracked_harness_is_spared() { + let snapshots = vec![snap(1002, BUNDLE_HARNESS)]; + let result = + select_untracked_bundle_harnesses(&snapshots, &PathBuf::from(BUNDLE_HARNESS), &[1002]); + assert!(result.is_empty()); + } + + #[test] + fn different_bundle_path_is_spared() { + let snapshots = vec![snap(1003, DEV_HARNESS)]; + let result = + select_untracked_bundle_harnesses(&snapshots, &PathBuf::from(BUNDLE_HARNESS), &[]); + assert!(result.is_empty()); + } + + #[test] + fn child_of_tracked_parent_not_directly_targeted() { + // A non-harness binary is never selected regardless of tracked state. + let snapshots = vec![snap(1004, "/Applications/Buzz.app/Contents/MacOS/goose")]; + let result = + select_untracked_bundle_harnesses(&snapshots, &PathBuf::from(BUNDLE_HARNESS), &[]); + assert!(result.is_empty()); + } + + #[test] + fn empty_process_list_returns_empty() { + let result = select_untracked_bundle_harnesses(&[], &PathBuf::from(BUNDLE_HARNESS), &[]); + assert!(result.is_empty()); + } + + #[test] + fn mixed_snapshot_kills_only_untracked_same_bundle() { + let snapshots = vec![ + snap(2001, BUNDLE_HARNESS), // tracked → spared + snap(2002, BUNDLE_HARNESS), // untracked → killed + snap(2003, DEV_HARNESS), // different path → spared + snap(2004, "/usr/bin/goose"), // unrelated → spared + ]; + let mut result = + select_untracked_bundle_harnesses(&snapshots, &PathBuf::from(BUNDLE_HARNESS), &[2001]); + result.sort(); + assert_eq!(result, vec![2002]); + } + + #[test] + fn deleted_suffix_stripped_path_matches_expected() { + // Snapshot with " (deleted)" suffix stripped → should match the clean expected path. + let raw = PathBuf::from("/Applications/Buzz.app/Contents/MacOS/buzz-acp (deleted)"); + let snaps = vec![ProcessSnapshot { + pid: 3001, + exe_path: strip_deleted_suffix(raw), + }]; + let result = select_untracked_bundle_harnesses(&snaps, &PathBuf::from(BUNDLE_HARNESS), &[]); + assert_eq!(result, vec![3001]); + } + + // ── walk_has_tracked_ancestor ──────────────────────────────────────── + + #[cfg(unix)] + fn map_parent(tree: &std::collections::HashMap, pid: u32) -> Option { + tree.get(&pid).copied() + } + + #[cfg(unix)] + #[test] + fn walk_direct_child_of_tracked_harness_is_exempted() { + // PID 101's parent is 100 (tracked) → live descendant, not an orphan. + let tree: std::collections::HashMap = [(101, 100)].into_iter().collect(); + assert!(walk_has_tracked_ancestor(101, &[100], |p| map_parent( + &tree, p + ))); + } + + #[cfg(unix)] + #[test] + fn walk_grandchild_via_own_group_wrapper_is_exempted() { + // Production tree: harness(100) → node-wrapper(101, own group) → codex-acp(102). + // One-level PPID check misses 102; the walk catches it. + let tree: std::collections::HashMap = + [(101, 100), (102, 101)].into_iter().collect(); + assert!(walk_has_tracked_ancestor(102, &[100], |p| map_parent( + &tree, p + ))); + } + + #[cfg(unix)] + #[test] + fn walk_real_orphan_ending_at_pid1_returns_false() { + // Genuine orphan: chain ends at init (PID 1), no tracked ancestor. + let tree: std::collections::HashMap = [(201, 1)].into_iter().collect(); + assert!(!walk_has_tracked_ancestor(201, &[100], |p| map_parent( + &tree, p + ))); + } + + #[cfg(unix)] + #[test] + fn walk_ppid_cycle_terminates_and_returns_false() { + // PPID cycle (a → b → a) from PID reuse must terminate, not loop. + let tree: std::collections::HashMap = + [(300, 301), (301, 300)].into_iter().collect(); + assert!(!walk_has_tracked_ancestor(300, &[999], |p| map_parent( + &tree, p + ))); + } + + #[cfg(unix)] + #[test] + fn walk_missing_parent_entry_returns_false() { + // proc_pidinfo / /proc stat failure (process exited) → not a descendant. + let tree: std::collections::HashMap = [].into_iter().collect(); + assert!(!walk_has_tracked_ancestor(400, &[100], |p| map_parent( + &tree, p + ))); + } + + #[cfg(unix)] + #[test] + fn walk_finds_ancestor_at_exact_depth_cap() { + // Chain with exactly 32 edges: 1000 → 1001 → … → 1032. + // The ancestor at hop 32 is within MAX_DEPTH and must be found. + let mut tree = std::collections::HashMap::new(); + for i in 0..32u32 { + tree.insert(1000 + i, 1000 + i + 1); + } + assert!(walk_has_tracked_ancestor(1000, &[1032], |p| map_parent( + &tree, p + ))); + } + + #[cfg(unix)] + #[test] + fn walk_misses_ancestor_beyond_depth_cap() { + // Chain with 33 edges: 1000 → 1001 → … → 1033. + // Hop 33 exceeds MAX_DEPTH (32) — the ancestor must not be found. + let mut tree = std::collections::HashMap::new(); + for i in 0..33u32 { + tree.insert(1000 + i, 1000 + i + 1); + } + assert!(!walk_has_tracked_ancestor(1000, &[1033], |p| map_parent( + &tree, p + ))); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 342bb1596c..06d3ac6db3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -143,13 +143,14 @@ fn fixture( model: None, provider: None, persona_source_version: None, - mcp_toolsets: None, env_vars: std::collections::BTreeMap::new(), start_on_app_launch: false, + auto_restart_on_config_change: true, runtime_pid: None, backend: Default::default(), backend_agent_id: None, provider_binary_path: None, + team_id: None, persona_team_dir: None, persona_name_in_team: None, created_at: "now".into(), @@ -158,8 +159,20 @@ fn fixture( last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to, respond_to_allowlist: allowlist, + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, relay_mesh: None, } } @@ -179,6 +192,8 @@ fn build_env_owner_only_sets_mode_and_removes_others() { assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER")); } +// select_untracked_bundle_harnesses tests live in runtime/sweep.rs (mod tests). + #[test] fn build_env_allowlist_sets_both_envs_and_joins() { let a = "a".repeat(64); @@ -260,8 +275,8 @@ fn persona_with_provider( prompt: &str, model: Option<&str>, provider: Option<&str>, -) -> crate::managed_agents::PersonaRecord { - crate::managed_agents::PersonaRecord { +) -> crate::managed_agents::AgentDefinition { + crate::managed_agents::AgentDefinition { id: id.to_string(), display_name: id.to_string(), avatar_url: None, @@ -275,38 +290,45 @@ fn persona_with_provider( source_team: None, source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, created_at: "2026-06-09T00:00:00Z".to_string(), updated_at: "2026-06-09T00:00:00Z".to_string(), } } -// ── persona pin/refresh acceptance (Phase 4) ──────────────────────────── +// ── persona env refresh acceptance ────────────────────────────────────── // -// The full lifecycle Will specified: create from P0, edit P0→P1 (env_vars -// included), restart stays pinned to P0, delete+respawn refreshes to P1. We -// exercise it at the pure seams that `create_managed_agent` and -// `build_managed_agent_summary` are built from: `persona_snapshot` (what create -// writes onto the record) and `persona_drift_state` (the Agents-menu badge). -// The env_var assertions are load-bearing — the credential pin is the field -// that would silently leak on restart if spawn re-read the live persona. +// The refresh lifecycle Wes decided: `record.env_vars` holds agent-level +// overrides only, the live persona env is merged underneath at read time +// (spawn / readiness / deploy), so persona env edits — like prompt/model/ +// provider — reach the agent on the next spawn without delete+recreate. +// The merge assertions are load-bearing: they witness the credential refresh +// that the old create-time env baking silently blocked. +use crate::managed_agents::env_vars::{live_persona_env, merged_user_env}; use crate::managed_agents::persona_events::persona_snapshot; use std::collections::BTreeMap; /// Apply a persona snapshot onto a record, mirroring `create_managed_agent`: -/// snapshotted prompt/model/provider/env_vars/source_version are pinned, with -/// the system_prompt unwrapped (the persona always carries one). -fn pin_persona(record: &mut ManagedAgentRecord, persona: &crate::managed_agents::PersonaRecord) { - let snapshot = persona_snapshot(persona, &record.env_vars); +/// snapshotted prompt/model/provider/source_version are pinned, with the +/// system_prompt unwrapped (the persona always carries one). `env_vars` is +/// deliberately NOT touched — it stays agent overrides only. +fn pin_persona(record: &mut ManagedAgentRecord, persona: &crate::managed_agents::AgentDefinition) { + let snapshot = persona_snapshot(persona); record.persona_id = Some(persona.id.clone()); record.system_prompt = snapshot.system_prompt; record.model = snapshot.model; record.provider = snapshot.provider; - record.env_vars = snapshot.env_vars; record.persona_source_version = Some(snapshot.source_version); } -fn persona_v(id: &str, prompt: &str, env: &[(&str, &str)]) -> crate::managed_agents::PersonaRecord { +fn persona_v( + id: &str, + prompt: &str, + env: &[(&str, &str)], +) -> crate::managed_agents::AgentDefinition { let mut p = persona_with_provider(id, prompt, Some("model-v"), Some("anthropic")); p.env_vars = env .iter() @@ -315,45 +337,63 @@ fn persona_v(id: &str, prompt: &str, env: &[(&str, &str)]) -> crate::managed_age p } +/// The spawn-time user env for `record` under `personas` — the same +/// live-persona-under-overrides merge `spawn_agent_child` and +/// `resolve_effective_agent_env` perform. +fn spawn_user_env( + record: &ManagedAgentRecord, + personas: &[crate::managed_agents::AgentDefinition], +) -> BTreeMap { + merged_user_env( + &live_persona_env(personas, record.persona_id.as_deref()), + &record.env_vars, + ) +} + #[test] -fn create_pins_full_persona_snapshot_including_env_vars() { +fn create_keeps_env_vars_as_overrides_only() { let p0 = persona_v("p", "prompt-v0", &[("ANTHROPIC_API_KEY", "key-v0")]); let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); pin_persona(&mut record, &p0); assert_eq!(record.system_prompt.as_deref(), Some("prompt-v0")); assert_eq!(record.provider.as_deref(), Some("anthropic")); + assert!( + record.env_vars.is_empty(), + "create must NOT bake persona env into the record — overrides only" + ); + // The credential still reaches the spawned process via the live merge. assert_eq!( - record.env_vars.get("ANTHROPIC_API_KEY").map(String::as_str), + spawn_user_env(&record, std::slice::from_ref(&p0)) + .get("ANTHROPIC_API_KEY") + .map(String::as_str), Some("key-v0"), - "create must pin persona env_vars — the credential pin" + "spawn env must carry the persona credential via the live merge" ); assert!(record.persona_source_version.is_some()); } #[test] -fn restart_after_persona_edit_stays_pinned_to_old_snapshot() { +fn restart_after_persona_edit_refreshes_credential() { // Create from P0. let p0 = persona_v("p", "prompt-v0", &[("ANTHROPIC_API_KEY", "key-v0")]); let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); pin_persona(&mut record, &p0); // Edit the persona to P1 (prompt + credential change). Restart reuses the - // SAME record — nothing rewrites the snapshot — so spawn reads P0 fields. + // SAME record, but spawn merges the live persona env — so the edited + // credential reaches the next spawn without delete+recreate. let p1 = persona_v("p", "prompt-v1", &[("ANTHROPIC_API_KEY", "key-v1")]); - assert_eq!( - record.system_prompt.as_deref(), - Some("prompt-v0"), - "restart must keep the pinned prompt" - ); - assert_eq!( - record.env_vars.get("ANTHROPIC_API_KEY").map(String::as_str), - Some("key-v0"), - "restart must NOT pick up the edited credential — the CRITICAL" + spawn_user_env(&record, std::slice::from_ref(&p1)) + .get("ANTHROPIC_API_KEY") + .map(String::as_str), + Some("key-v1"), + "restart must pick up the edited credential — the refresh Wes asked for" ); - // The badge flips: the record's snapshot now lags the edited persona. + // The badge flips: the record's snapshot lags the edited persona until the + // spawn-path re-snapshot runs. let (out_of_date, orphaned) = super::persona_drift_state(&record, std::slice::from_ref(&p1)); assert!( out_of_date, @@ -363,52 +403,78 @@ fn restart_after_persona_edit_stays_pinned_to_old_snapshot() { } #[test] -fn respawn_after_persona_edit_refreshes_to_new_snapshot() { - let p0 = persona_v("p", "prompt-v0", &[("ANTHROPIC_API_KEY", "key-v0")]); - let p1 = persona_v("p", "prompt-v1", &[("ANTHROPIC_API_KEY", "key-v1")]); - - // Respawn = delete the old record + create a fresh one. create re-snapshots - // the now-current persona (P1). - let mut respawned = fixture(RespondTo::Anyone, vec![], Some("tag".into())); - pin_persona(&mut respawned, &p1); - - assert_eq!(respawned.system_prompt.as_deref(), Some("prompt-v1")); - assert_eq!( - respawned - .env_vars - .get("ANTHROPIC_API_KEY") - .map(String::as_str), - Some("key-v1"), - "respawn must refresh the credential to the edited persona" - ); - - // Now pinned to current persona → not out of date. - let (out_of_date, orphaned) = super::persona_drift_state(&respawned, std::slice::from_ref(&p1)); - assert!(!out_of_date, "respawn pins to current persona — no drift"); - assert!(!orphaned); - - // Sanity: P0 differs from P1, so a record still pinned to P0 would drift. - let mut stale = fixture(RespondTo::Anyone, vec![], Some("tag".into())); - pin_persona(&mut stale, &p0); - assert!(super::persona_drift_state(&stale, std::slice::from_ref(&p1)).0); -} - -#[test] -fn agent_env_overrides_win_over_persona_env_in_snapshot() { - // Agent-level env_vars (input.env_vars) layer over persona env on collision, - // matching spawn precedence (persona env < agent env). +fn agent_env_overrides_win_over_persona_env_at_spawn() { + // Agent-level env_vars layer over persona env on collision at read time + // (persona env < agent env). let persona = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "persona-key")]); let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); record.env_vars = BTreeMap::from([("ANTHROPIC_API_KEY".to_string(), "agent-key".to_string())]); pin_persona(&mut record, &persona); assert_eq!( - record.env_vars.get("ANTHROPIC_API_KEY").map(String::as_str), + spawn_user_env(&record, std::slice::from_ref(&persona)) + .get("ANTHROPIC_API_KEY") + .map(String::as_str), Some("agent-key"), "agent override must win over persona env" ); } +#[test] +fn orphaned_agent_spawns_from_its_own_overrides() { + // Persona deleted: the live merge degrades to the record's own overrides. + let persona = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "persona-key")]); + let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + record.env_vars = BTreeMap::from([("EXTRA".to_string(), "agent-value".to_string())]); + pin_persona(&mut record, &persona); + + let env = spawn_user_env(&record, &[]); + assert_eq!(env.get("EXTRA").map(String::as_str), Some("agent-value")); + assert_eq!( + env.get("ANTHROPIC_API_KEY"), + None, + "a deleted persona's env must not linger" + ); +} + +#[test] +fn self_heal_drops_overrides_equal_to_persona_value() { + // Pre-refresh records baked persona env in as pseudo-overrides. The + // spawn-path retain() treats an override equal to the persona's current + // value as inherited, so later persona edits refresh it. + let p0 = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "key-v0")]); + let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + record.env_vars = BTreeMap::from([ + ("ANTHROPIC_API_KEY".to_string(), "key-v0".to_string()), // baked-in persona value + ("GENUINE".to_string(), "override".to_string()), // real override + ]); + pin_persona(&mut record, &p0); + + // Mirror the start/restore self-heal. + record.env_vars.retain(|k, v| p0.env_vars.get(k) != Some(v)); + + assert_eq!( + record.env_vars.get("ANTHROPIC_API_KEY"), + None, + "baked-in persona value must be reclassified as inherited" + ); + assert_eq!( + record.env_vars.get("GENUINE").map(String::as_str), + Some("override"), + "genuine overrides must survive the self-heal" + ); + + // After the persona edits the key, the healed record refreshes. + let p1 = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "key-v1")]); + assert_eq!( + spawn_user_env(&record, std::slice::from_ref(&p1)) + .get("ANTHROPIC_API_KEY") + .map(String::as_str), + Some("key-v1"), + "healed record must inherit the edited persona credential" + ); +} + #[test] fn deleted_persona_is_orphaned_not_out_of_date() { let p0 = persona_v("p", "prompt-v0", &[("KEY", "v0")]); @@ -606,3 +672,91 @@ fn grandchild_inherits_pgid_of_process_group_leader() { unsafe { libc::kill(-harness_pid, libc::SIGTERM) }; let _ = harness.wait(); } + +/// Validates that `walk_has_tracked_ancestor` catches the production case the +/// old PGID check missed: the intermediate process is in its OWN process group +/// (mirroring the node npm-shim wrapper that starts `codex-acp`). The +/// grandchild's PGID matches the intermediate's PID, not the harness's — so +/// `skip_pids.contains(&grandchild_pgid)` returns false. The ancestor walk +/// must still find the harness as an ancestor and return true. +#[cfg(unix)] +#[test] +fn own_group_grandchild_detected_by_ancestor_walk() { + use std::os::unix::process::CommandExt; + use std::process::Command; + + // The test process is the "harness". Spawn an intermediate with its own + // process group (mirrors the node shim). It backgrounds a grandchild + // (sleep 30) and prints the grandchild PID so we can inspect it. + let mut intermediate = { + let mut cmd = Command::new("sh"); + cmd.args(["-c", "sleep 30 & echo $!; wait"]) + .stdout(std::process::Stdio::piped()) + .process_group(0); + cmd.spawn().expect("spawn intermediate") + }; + + use std::io::BufRead; + let stdout = intermediate.stdout.take().unwrap(); + let reader = std::io::BufReader::new(stdout); + let grandchild_pid: u32 = reader + .lines() + .next() + .expect("should get a line") + .expect("should read line") + .trim() + .parse() + .expect("should parse grandchild PID"); + + let intermediate_pid = intermediate.id(); + let harness_pid = std::process::id(); + + // The intermediate is its own process group leader. + let intermediate_pgid = unsafe { libc::getpgid(intermediate_pid as i32) }; + assert_eq!( + intermediate_pgid, intermediate_pid as i32, + "intermediate should be its own process group leader" + ); + + // The grandchild inherits the intermediate's group — NOT the harness's. + let grandchild_pgid = unsafe { libc::getpgid(grandchild_pid as i32) }; + assert_eq!( + grandchild_pgid, intermediate_pid as i32, + "grandchild PGID should be the intermediate, not the harness" + ); + assert_ne!( + grandchild_pgid, harness_pid as i32, + "grandchild PGID must not equal harness PID — this is the false-positive shape" + ); + + // The ancestor walk finds the harness even though PGID doesn't match it. + let skip_pids = vec![harness_pid]; + let found = + super::sweep::walk_has_tracked_ancestor(grandchild_pid, &skip_pids, super::sweep::ppid_of); + assert!( + found, + "walk must detect grandchild as a live descendant of the tracked harness" + ); + + // Contrast: empty skip_pids → not a descendant of any tracked harness. + let not_found = + super::sweep::walk_has_tracked_ancestor(grandchild_pid, &[], super::sweep::ppid_of); + assert!( + !not_found, + "walk with empty skip_pids must return false for a real orphan" + ); + + // Guard against PID reuse: verify the intermediate is still alive before + // cleanup so a recycled PID can't corrupt the kill target. + assert!( + intermediate + .try_wait() + .expect("try_wait on intermediate") + .is_none(), + "intermediate exited before cleanup — its PID may have been recycled" + ); + + // Cleanup: SIGKILL the intermediate's process group (takes sleep 30 with it). + unsafe { libc::kill(-(intermediate_pid as i32), libc::SIGKILL) }; + let _ = intermediate.wait(); +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs new file mode 100644 index 0000000000..63a57ce5da --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -0,0 +1,142 @@ +//! Spawn-time config hash for the restart-required badge. +//! +//! [`spawn_config_hash`] digests the *effective spawned values* — what a +//! process launch of `record` would actually receive — so the UI can compare +//! a running process's hash (stamped on [`super::ManagedAgentProcess`] at +//! spawn) against a recomputation from current disk state and show a +//! "restart required" badge only when a restart would change what runs. +//! +//! Scope rules (decided in #centralize-personas-and-agents, revised in PR +//! #1602 review): +//! - Inputs mirror what a start would actually run: the start/restore paths +//! re-snapshot the linked persona's prompt/model/provider/env onto the +//! record immediately before spawning (`start_local_agent_with_preflight`, +//! `restore_managed_agents_on_launch`), so persona edits to those fields DO +//! apply on a plain restart and are hashed via the same prospective +//! re-snapshot. Harness command, args/mcp, env layering, and the record +//! fields the spawn env writes read are hashed as spawn resolves them. +//! - The relay URL is hashed in resolved form (`effective_agent_relay_url`): +//! a record with a blank relay spawns against the active workspace relay, +//! so a workspace relay change means a restart would change what runs. +//! - Channel membership is not an input: agents pick up channel changes live +//! (#1468), never via restart. +//! +//! The hash never crosses a process or persistence boundary, so +//! `DefaultHasher` (not stable across Rust releases) is sufficient. + +use std::hash::{DefaultHasher, Hash, Hasher}; + +use super::{ + known_acp_runtime, normalize_agent_args, + persona_events::apply_persona_snapshot, + resolve_effective_agent_env, + types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, + GlobalAgentConfig, +}; + +/// The prompt a spawn would actually deliver: `Some("")` collapses to `None` +/// because an empty `BUZZ_ACP_SYSTEM_PROMPT` is no prompt. +/// +/// The single source of truth for the spawn env write AND the config hash. +pub(crate) fn effective_spawn_prompt(record: &ManagedAgentRecord) -> Option { + record + .system_prompt + .clone() + .filter(|prompt| !prompt.is_empty()) +} + +/// Resolve the current instructions for this instance's deployment-time team binding. +/// A deleted team deliberately degrades to no team section. +pub(crate) fn effective_team_instructions( + record: &ManagedAgentRecord, + teams: &[TeamRecord], +) -> Option { + teams + .iter() + .find(|team| Some(team.id.as_str()) == record.team_id.as_deref()) + .and_then(|team| team.instructions.as_deref()) + .map(str::trim) + .filter(|instructions| !instructions.is_empty()) + .map(str::to_string) +} + +/// Digest the effective spawn configuration of `record` under the current +/// `personas`, resolving a blank record relay against `workspace_relay`. +/// Pure — no `AppHandle`, no disk, no keyring. +pub(crate) fn spawn_config_hash( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + teams: &[TeamRecord], + workspace_relay: &str, + global: &GlobalAgentConfig, +) -> u64 { + // Prospective re-snapshot: apply the same `apply_persona_snapshot` the + // start/restore paths run right before spawning, so the hash covers what a + // restart would actually run. Idempotent, so the spawn-time stamp + // (post-snapshot record) and later recomputes (persisted record) agree + // when nothing changed. The persona env itself reaches the hash through + // `resolve_effective_agent_env` below; `persona_source_version` is set on + // the clone but is not a hash input. + let mut record = record.clone(); + if let Some(persona_id) = record.persona_id.clone() { + if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { + apply_persona_snapshot(&mut record, persona); + } + } + let record = &record; + + let effective_command = crate::managed_agents::record_agent_command(record, personas); + let runtime_meta = known_acp_runtime(&effective_command); + let effective = resolve_effective_agent_env(record, personas, runtime_meta, global); + + let mut hasher = DefaultHasher::new(); + + // Harness identity and derivations (live-persona-resolved, like spawn). + record.acp_command.hash(&mut hasher); + effective_command.hash(&mut hasher); + normalize_agent_args(&effective_command, record.agent_args.clone()).hash(&mut hasher); + runtime_meta + .and_then(|r| r.mcp_command) + .unwrap_or("") + .hash(&mut hasher); + + // Effective env layering (baked floor → runtime metadata → user env). + // BTreeMap iteration is ordered, so this is deterministic. + effective.env.hash(&mut hasher); + + // Record fields the spawn env writes read directly. The relay is hashed + // resolved: a blank record relay spawns on the workspace relay, so a + // workspace relay change must trip the badge. + crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher); + // Prompt and runtime-layered team instructions use the same resolver as spawn. + effective_spawn_prompt(record).hash(&mut hasher); + effective_team_instructions(record, teams).hash(&mut hasher); + record.model.hash(&mut hasher); + record.provider.hash(&mut hasher); + record.auth_tag.hash(&mut hasher); + record.respond_to.as_str().hash(&mut hasher); + // The allowlist is hashed as the env receives it: spawn sets + // BUZZ_ACP_RESPOND_TO_ALLOWLIST only in allowlist mode, and normalized + // (trim/lowercase/dedup via `validate_respond_to_allowlist`) — so edits + // that don't survive normalization, or edits while another mode is + // active, must not badge. A list spawn would reject hashes raw: the + // stamped hash comes from a successful spawn, so any invalid edit + // correctly compares unequal. + if record.respond_to == super::types::RespondTo::Allowlist { + super::types::validate_respond_to_allowlist(&record.respond_to_allowlist) + .unwrap_or_else(|_| record.respond_to_allowlist.clone()) + .hash(&mut hasher); + } + record.idle_timeout_seconds.hash(&mut hasher); + // Spawn writes BUZZ_ACP_MAX_TURN_DURATION with a default. + record + .max_turn_duration_seconds + .unwrap_or(super::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS) + .hash(&mut hasher); + record.parallelism.hash(&mut hasher); + + hasher.finish() +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs new file mode 100644 index 0000000000..9b79881625 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -0,0 +1,436 @@ +use super::*; +use crate::managed_agents::types::RespondTo; +use std::collections::BTreeMap; + +fn record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".repeat(64), + name: "agent".into(), + persona_id: None, + private_key_nsec: "nsec1fake".into(), + auth_tag: None, + relay_url: "ws://localhost:3000".into(), + avatar_url: None, + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: Some("You are a test agent.".into()), + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} + +fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { + AgentDefinition { + id: id.into(), + display_name: id.into(), + avatar_url: None, + system_prompt: prompt.into(), + runtime: runtime.map(str::to_string), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "now".into(), + updated_at: "now".into(), + } +} + +#[test] +fn hash_is_deterministic() { + let rec = record(); + assert_eq!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn materializing_runtime_keeps_hash_stable() { + // Migration cutover invariant (Phase 1A): materializing the linked + // persona's runtime onto the record must NOT change the spawn hash — + // otherwise every running persona-linked agent would show a spurious + // restart badge right after migration. Pre-migration the command resolves + // through the persona fallback; post-migration through record.runtime. + // Same persona, same runtime, same command → same hash. + let personas = vec![persona("p1", Some("goose"), "Persona prompt.")]; + + let mut pre = record(); + pre.persona_id = Some("p1".into()); + + let mut post = pre.clone(); + post.runtime = Some("goose".into()); + + assert_eq!( + spawn_config_hash( + &pre, + &personas, + &[], + "wss://ws.example", + &Default::default() + ), + spawn_config_hash( + &post, + &personas, + &[], + "wss://ws.example", + &Default::default() + ) + ); +} + +#[test] +fn record_env_var_edit_changes_hash() { + let rec = record(); + let mut edited = record(); + edited + .env_vars + .insert("SOME_KEY".into(), "some-value".into()); + assert_ne!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn record_prompt_edit_changes_hash() { + let rec = record(); + let mut edited = record(); + edited.system_prompt = Some("Edited prompt.".into()); + assert_ne!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn persona_runtime_edit_changes_hash() { + // The harness command resolves live personas at spawn, so a persona + // runtime change means a restart WOULD change what runs → badge trips. + let mut rec = record(); + rec.persona_id = Some("pers".into()); + let before = [persona("pers", Some("goose"), "prompt")]; + let after = [persona("pers", Some("claude"), "prompt")]; + assert_ne!( + spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn persona_prompt_edit_changes_hash() { + // Start/restore re-snapshot the persona prompt onto the record right + // before spawning, so a persona prompt edit DOES apply on a plain + // restart → the badge must trip. + let mut rec = record(); + rec.persona_id = Some("pers".into()); + let before = [persona("pers", Some("goose"), "old prompt")]; + let after = [persona("pers", Some("goose"), "new prompt")]; + assert_ne!( + spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn workspace_relay_change_trips_hash_for_blank_record_relay() { + // A blank record relay spawns against the active workspace relay, so a + // workspace relay change means a restart would change what runs. + let mut rec = record(); + rec.relay_url = String::new(); + assert_ne!( + spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), + spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default()) + ); +} + +#[test] +fn workspace_relay_change_ignored_for_pinned_record_relay() { + // An explicit per-agent relay pins the agent regardless of workspace, so + // a workspace relay change must NOT badge a pinned agent. + let rec = record(); + assert_eq!( + spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), + spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default()) + ); +} + +#[test] +fn respond_to_allowlist_edit_changes_hash() { + let rec = record(); + let mut edited = record(); + edited.respond_to = RespondTo::Allowlist; + edited.respond_to_allowlist = vec!["a".repeat(64)]; + assert_ne!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn allowlist_ignored_when_mode_is_not_allowlist() { + // Spawn only sets BUZZ_ACP_RESPOND_TO_ALLOWLIST in allowlist mode, so + // editing the (dormant) list under owner-only must not badge. + let rec = record(); + let mut edited = record(); + edited.respond_to_allowlist = vec!["a".repeat(64)]; + assert_eq!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn allowlist_normalization_equivalent_edits_do_not_change_hash() { + // The env receives the normalized list (trim/lowercase/dedup), so edits + // that normalize to the same value must not badge. + let mut rec = record(); + rec.respond_to = RespondTo::Allowlist; + rec.respond_to_allowlist = vec!["a".repeat(64)]; + let mut edited = rec.clone(); + edited.respond_to_allowlist = vec![ + format!(" {} ", "A".repeat(64)), // whitespace + case + "a".repeat(64), // duplicate + ]; + assert_eq!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn allowlist_content_edit_still_changes_hash() { + let mut rec = record(); + rec.respond_to = RespondTo::Allowlist; + rec.respond_to_allowlist = vec!["a".repeat(64)]; + let mut edited = rec.clone(); + edited.respond_to_allowlist = vec!["b".repeat(64)]; + assert_ne!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn explicit_default_max_turn_duration_does_not_change_hash() { + // Spawn writes BUZZ_ACP_MAX_TURN_DURATION with the default filled in, so + // None → Some(default) is the same spawned value and must not badge. + let rec = record(); + let mut edited = record(); + edited.max_turn_duration_seconds = + Some(crate::managed_agents::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS); + assert_eq!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn non_default_max_turn_duration_changes_hash() { + let rec = record(); + let mut edited = record(); + edited.max_turn_duration_seconds = Some(42); + assert_ne!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn non_spawn_bookkeeping_fields_do_not_change_hash() { + // updated_at / runtime_pid / last_* are lifecycle bookkeeping, not spawn + // inputs — routine record saves must not trip the badge. + let rec = record(); + let mut edited = record(); + edited.updated_at = "later".into(); + edited.runtime_pid = Some(12345); + edited.last_started_at = Some("later".into()); + edited.last_exit_code = Some(0); + assert_eq!( + spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + ); +} + +#[test] +fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() { + // B5 hash row 3: the prospective re-snapshot copies ONLY + // prompt/model/provider/env from the linked definition. An instance + // whose owner hand-set respond_to/allowlist/parallelism must + // hash identically whether or not its definition carries a quad — + // activation of the definition-level defaults must never reach through + // spawn and overwrite instance state. + let quadless_definition = vec![persona("p1", Some("goose"), "Persona prompt.")]; + + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.respond_to = RespondTo::Allowlist; + rec.respond_to_allowlist = vec!["a".repeat(64)]; + rec.parallelism = 4; + + let mut definition_with_quad = quadless_definition.clone(); + definition_with_quad[0].respond_to = Some("anyone".into()); + definition_with_quad[0].parallelism = Some(8); + + assert_eq!( + spawn_config_hash( + &rec, + &quadless_definition, + &[], + "wss://ws.example", + &Default::default() + ), + spawn_config_hash( + &rec, + &definition_with_quad, + &[], + "wss://ws.example", + &Default::default() + ), + "definition quad must not leak into the spawn hash of an existing instance" + ); +} + +#[test] +fn empty_prompt_hashes_like_absent_prompt() { + // B5 hash row 2 foundation: Some("") and None spawn identically (env var + // absent either way), so they must hash equal — a backfilled prompt-less + // record re-snapshots to Some("") and must not trip the badge. + let mut absent = record(); + absent.system_prompt = None; + let mut empty = record(); + empty.system_prompt = Some(String::new()); + assert_eq!( + spawn_config_hash(&absent, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&empty, &[], &[], "wss://ws.example", &Default::default()), + ); +} + +/// (a) A definition-runtime edit must change spawn_config_hash for a +/// materialized, override-free record — the prospective re-snapshot now +/// copies the persona's runtime onto the record before hashing. +#[test] +fn definition_runtime_edit_changes_hash_for_materialized_record() { + let mut rec = record(); + rec.persona_id = Some("pers".into()); + rec.runtime = Some("goose".into()); // materialized runtime on instance + + let before = [persona("pers", Some("goose"), "prompt")]; + let after = [persona("pers", Some("claude"), "prompt")]; + assert_ne!( + spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + "definition runtime edit must badge a materialized, override-free instance" + ); +} + +/// (c) An explicit agent_command_override (ladder step 1) must beat a +/// changed definition runtime — the badge must NOT fire for a pinned instance. +#[test] +fn agent_command_override_beats_definition_runtime_change() { + let mut rec = record(); + rec.persona_id = Some("pers".into()); + rec.runtime = Some("goose".into()); // materialized runtime + rec.agent_command_override = Some("goose".into()); // explicit per-instance pin + + let before = [persona("pers", Some("goose"), "prompt")]; + let after = [persona("pers", Some("claude"), "prompt")]; + assert_eq!( + spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + "explicit override must win regardless of definition runtime change" + ); +} + +/// (d) When the linked definition is absent the prospective re-snapshot is +/// skipped entirely: the materialized runtime must still affect the hash. +#[test] +fn missing_definition_leaves_materialized_runtime_in_hash() { + let mut rec = record(); + rec.persona_id = Some("missing".into()); + rec.runtime = Some("goose".into()); // materialized runtime + + let no_personas: &[AgentDefinition] = &[]; + + let mut no_runtime = rec.clone(); + no_runtime.runtime = None; + + assert_ne!( + spawn_config_hash( + &rec, + no_personas, + &[], + "wss://ws.example", + &Default::default() + ), + spawn_config_hash( + &no_runtime, + no_personas, + &[], + "wss://ws.example", + &Default::default() + ), + "materialized runtime must still affect hash when definition is absent" + ); +} + +#[test] +fn effective_spawn_prompt_matches_hash_semantics() { + // The env write and the hash share effective_spawn_prompt — this row + // pins the helper's own semantics so a refactor of either caller cannot + // silently diverge from the contract. + let mut r = record(); + r.system_prompt = Some(String::new()); + assert_eq!( + effective_spawn_prompt(&r), + None, + "empty collapses to absent" + ); + r.system_prompt = Some("real".into()); + assert_eq!(effective_spawn_prompt(&r).as_deref(), Some("real")); +} diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 0b11933664..2a48976e1f 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, fs::{self, File, OpenOptions}, io::{Read as _, Seek, SeekFrom, Write}, path::{Path, PathBuf}, @@ -6,7 +7,7 @@ use std::{ use tauri::{AppHandle, Manager}; -use crate::app_state::KEYRING_SERVICE; +use crate::app_state::keyring_service; use crate::managed_agents::ManagedAgentRecord; use crate::secret_store::{KeyringProbe, SecretStore}; @@ -23,7 +24,7 @@ fn agent_keyring_name(pubkey: &str) -> String { /// races on concurrent blob writes. fn agent_secret_store() -> Option<&'static SecretStore> { if cfg!(feature = "system-keyring") { - Some(SecretStore::shared(KEYRING_SERVICE)) + Some(SecretStore::shared(keyring_service())) } else { None } @@ -39,7 +40,7 @@ pub fn managed_agents_base_dir(app: &AppHandle) -> Result { Ok(dir) } -fn managed_agents_store_path(app: &AppHandle) -> Result { +pub(crate) fn managed_agents_store_path(app: &AppHandle) -> Result { Ok(managed_agents_base_dir(app)?.join("managed-agents.json")) } @@ -61,9 +62,15 @@ trait KeyStore { /// Read a key. `Ok(None)` is "no such entry" (absent); `Err` is a backend /// failure (keyring unreachable) — the caller MUST NOT collapse the two. fn load(&self, name: &str) -> Result, String>; + /// Read the entire blob as a map without any side effects. + /// `Ok(None)` when no blob exists yet; `Err` only on backend failure. + /// Callers must not call `migrate_legacy_key` — this is a read-only view. + fn load_all_readonly(&self) -> Result>, String>; /// Write `value` and read it back to confirm before the caller strips the /// inline copy. fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String>; + /// Insert all entries from `entries` in a single blob mutation. + fn store_all(&self, entries: &HashMap) -> Result<(), String>; } impl KeyStore for SecretStore { @@ -73,6 +80,9 @@ impl KeyStore for SecretStore { fn load(&self, name: &str) -> Result, String> { SecretStore::load(self, name) } + fn load_all_readonly(&self) -> Result>, String> { + SecretStore::load_all_readonly(self) + } fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String> { self.store(name, value)?; match self.load(name)? { @@ -80,6 +90,9 @@ impl KeyStore for SecretStore { _ => Err("keyring read-back verify failed".to_string()), } } + fn store_all(&self, entries: &HashMap) -> Result<(), String> { + SecretStore::store_all(self, entries) + } } /// Outcome of attempting to lift a record's inline key into the keyring. @@ -147,7 +160,9 @@ pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option { }) } -pub fn load_managed_agents(app: &AppHandle) -> Result, String> { +/// Read the raw unified store — keyed instances AND key-less definitions — +/// with fail-loud parse handling. Internal seam; public readers filter. +fn load_agent_store(app: &AppHandle) -> Result, String> { let path = managed_agents_store_path(app)?; if !path.exists() { return Ok(Vec::new()); @@ -155,13 +170,54 @@ pub fn load_managed_agents(app: &AppHandle) -> Result, S let content = fs::read_to_string(&path) .map_err(|error| format!("failed to read agent store: {error}"))?; - let mut records: Vec = serde_json::from_str(&content) - .map_err(|error| format!("failed to parse agent store: {error}"))?; + serde_json::from_str(&content).map_err(|error| { + // Fail loudly and preserve the evidence: a later in-app save rewrites + // this file wholesale, which would silently destroy a malformed hand + // edit. Best-effort file-authoring contract (see managed_agents:: + // reconcile): the broken content survives as `.invalid` for the user + // to recover, and the parse error propagates instead of being + // swallowed into an empty store. + backup_invalid_store(&path); + format!("failed to parse agent store (preserved as .invalid): {error}") + }) +} +/// Load the keyed agent *instances*. Key-less definitions (former personas, +/// folded into the same store) are filtered out so every pre-fold call site +/// keeps seeing exactly the records it always did. +pub fn load_managed_agents(app: &AppHandle) -> Result, String> { + let mut records = load_agent_store(app)?; + records.retain(|record| !record.pubkey.is_empty()); hydrate_keys(&mut records); Ok(records) } +/// Load the key-less agent *definitions* (former personas) from the unified +/// store. The persona compatibility shim (`load_personas`) presents these in +/// the legacy shape via `to_definition_view`. +pub(crate) fn load_agent_definitions(app: &AppHandle) -> Result, String> { + let mut records = load_agent_store(app)?; + records.retain(|record| record.pubkey.is_empty()); + Ok(records) +} + +/// Preserve a malformed store file as `.invalid` before the error path +/// unwinds. Copy, not rename: the original stays in place so repeated boots +/// keep failing loudly (rename would make the next launch look like a fresh +/// install and mint an empty store over the evidence). Overwrites any prior +/// `.invalid` — the newest broken content is the one worth keeping. Failure +/// here is logged and swallowed; it must never mask the parse error itself. +pub(crate) fn backup_invalid_store(path: &Path) { + let backup = path.with_extension("json.invalid"); + if let Err(e) = fs::copy(path, &backup) { + eprintln!( + "buzz-desktop: failed to preserve malformed store {} as {}: {e}", + path.display(), + backup.display() + ); + } +} + /// Fill in each record's in-memory `private_key_nsec` from the keyring, and /// opportunistically re-migrate any key that is still inline. /// @@ -189,6 +245,11 @@ fn hydrate_keys(records: &mut [ManagedAgentRecord]) { /// key this boot." fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) { for record in records.iter_mut() { + // A key-less definition (no pubkey yet — unified agent model) has no + // keyring entry by construction; keys are minted on first start. + if record.pubkey.is_empty() { + continue; + } if record.private_key_nsec.is_empty() { match store.load(&agent_keyring_name(&record.pubkey)) { Ok(Some(nsec)) => record.private_key_nsec = nsec, @@ -220,8 +281,17 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) } } +/// Save the keyed agent *instances*, preserving the key-less definitions that +/// share the unified store: callers pass exactly the records they loaded via +/// [`load_managed_agents`], and this re-reads the definition half from disk +/// before the wholesale rewrite so a definition is never dropped by an +/// instance-side save (and vice versa via [`save_agent_definitions`]). pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { + let definitions = load_agent_definitions(app).unwrap_or_default(); let mut sorted = records.to_vec(); + // A caller-supplied key-less record would collide with the definition + // half re-read below; instances always carry a pubkey. + sorted.retain(|record| !record.pubkey.is_empty()); sorted.sort_by(|left, right| { left.name .to_lowercase() @@ -234,8 +304,36 @@ pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> R // keyring is unreachable, the key stays inline. persist_agent_keys(&mut sorted); + write_agent_store(app, definitions, sorted) +} + +/// Save the key-less agent *definitions*, preserving the keyed instances — +/// the definition-side mirror of [`save_managed_agents`]. +pub(crate) fn save_agent_definitions( + app: &AppHandle, + definitions: &[ManagedAgentRecord], +) -> Result<(), String> { + let mut instances = load_agent_store(app)?; + instances.retain(|record| !record.pubkey.is_empty()); + let mut definitions = definitions.to_vec(); + definitions.retain(|record| record.pubkey.is_empty()); + write_agent_store(app, definitions, instances) +} + +/// Serialize definitions + instances into the single unified store file. +/// Definitions sort first (by slug) for stable diffs; instances keep the +/// name/pubkey order their save path established. +fn write_agent_store( + app: &AppHandle, + mut definitions: Vec, + instances: Vec, +) -> Result<(), String> { + definitions.sort_by(|left, right| left.slug.cmp(&right.slug)); + let mut all = definitions; + all.extend(instances); + let path = managed_agents_store_path(app)?; - let payload = serde_json::to_vec_pretty(&sorted) + let payload = serde_json::to_vec_pretty(&all) .map_err(|error| format!("failed to serialize agent store: {error}"))?; // `managed-agents.json` carries plaintext agent nsecs in the keyringless @@ -270,13 +368,156 @@ fn persist_agent_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRec } } } + +/// One-time migration of agent keys from the production keyring service +/// (`"buzz-desktop"`) to the dev service (`"buzz-desktop-dev"`). Only runs +/// in debug builds — release builds never touch `"buzz-desktop"` from this +/// path. +/// +/// Idempotent: skips any key that already exists in the dev service so +/// repeated boots after migration are no-ops. Leaves the production keyring +/// untouched — a dev build and a prod install can coexist without sharing +/// keys after this migration. +/// +/// Call this at boot before `hydrate_keys` runs (i.e. before +/// `load_managed_agents` is called) so agents find their keys on first boot +/// after the service-name change. +#[cfg(debug_assertions)] +pub fn migrate_agent_keys_to_dev_service(app: &tauri::AppHandle) { + if !cfg!(feature = "system-keyring") { + return; + } + + // Read the JSON store for pubkeys only — we want every instance + // record without running hydrate_keys (which would try the dev + // keyring that is empty, and log noisy "has no key" warnings). + let records = match load_agent_store(app) { + Ok(r) => r, + Err(e) => { + eprintln!("buzz-desktop: keyring-dev-migration: cannot read agent store: {e}"); + return; + } + }; + + let pubkeys: Vec = records + .into_iter() + .filter(|r| !r.pubkey.is_empty()) + .map(|r| r.pubkey) + .collect(); + // A fresh non-singleton store for the prod service — its own empty + // cache so reads go to the OS keyring without polluting the dev + // singleton's cache. + let prod_store = crate::secret_store::SecretStore::keyring("buzz-desktop"); + let dev_store = crate::secret_store::SecretStore::shared(keyring_service()); + copy_agent_keys_between_stores(&pubkeys, &prod_store, dev_store); +} + +/// Marker key stored inside the dev blob after a successful agent-key migration. +/// Its presence means all agent keys that existed in the prod service at +/// migration time have been copied; subsequent dev boots skip the migration +/// entirely (no prod keyring access). +#[cfg(debug_assertions)] +const DEV_MIGRATION_MARKER: &str = "_dev_migration_v1"; + +/// Testable core of [`migrate_agent_keys_to_dev_service`]: copy `agent:` +/// entries from `src` to `dst` for each pubkey, then write a migration-complete +/// marker so future boots skip the entire function with zero prod-keyring access. +/// +/// On the first migration boot: +/// 1. One `dst.load_all_readonly()` — dev blob read (1 keychain prompt) +/// 2. One `src.load_all_readonly()` — prod blob read (1 keychain prompt) +/// 3. One `dst.store_all()` — dev blob write (same service as #1; macOS may +/// skip the ACL prompt if the initial grant was "Always Allow") +/// +/// On subsequent boots (marker already present): +/// 1. One `dst.load_all_readonly()` — dev blob read (1 keychain prompt) +/// Returns immediately — prod keyring is NEVER accessed. +/// +/// Idempotency: keys already present in `dst` are not overwritten (the agent +/// may have rotated their key in the dev service after initial migration). +/// New agents (pubkey not in `src`) are silently skipped — they will mint a +/// fresh key on their next onboarding run. +#[cfg(debug_assertions)] +fn copy_agent_keys_between_stores(pubkeys: &[String], src: &impl KeyStore, dst: &impl KeyStore) { + // One read of the dev blob. If the migration-complete marker is present, + // all prior agent keys are already in the dev service — skip entirely. + let dst_map: HashMap = match dst.load_all_readonly() { + Ok(Some(map)) if map.contains_key(DEV_MIGRATION_MARKER) => { + return; // already migrated: 0 prod keyring accesses + } + Ok(Some(map)) => map, + Ok(None) => HashMap::new(), + Err(e) => { + eprintln!("buzz-desktop: keyring-dev-migration: cannot read dev keyring: {e}"); + return; + } + }; + // Skip production when a reset left no agents or onboarding created every dev key. + let src_map: HashMap = if pubkeys + .iter() + .all(|pubkey| dst_map.contains_key(&agent_keyring_name(pubkey))) + { + HashMap::new() + } else { + match src.load_all_readonly() { + Ok(Some(map)) => map, + Ok(None) => HashMap::new(), // prod has no blob yet — nothing to copy + Err(e) => { + eprintln!("buzz-desktop: keyring-dev-migration: cannot read prod keyring: {e}"); + return; + } + } + }; + + // Compute the set of entries to write: agent keys absent from dst, plus + // the migration-complete marker. + let mut to_write: HashMap = HashMap::new(); + let mut copied = 0usize; + for pubkey in pubkeys { + let name = agent_keyring_name(pubkey); + if dst_map.contains_key(&name) { + continue; // already in dev service — do not overwrite (idempotent) + } + if let Some(nsec) = src_map.get(&name) { + to_write.insert(name, nsec.clone()); + copied += 1; + } + // absent from src → new agent, will mint a fresh key + } + + // Always write the marker so future boots skip the prod read entirely, + // even when there were no keys to copy (empty dev environment). + to_write.insert(DEV_MIGRATION_MARKER.to_string(), "done".to_string()); + + if let Err(e) = dst.store_all(&to_write) { + eprintln!("buzz-desktop: keyring-dev-migration: cannot write to dev keyring: {e}"); + return; + } + + if copied > 0 { + eprintln!( + "buzz-desktop: keyring-dev-migration: copied {copied} agent key(s) from buzz-desktop" + ); + } +} + +/// Remove an agent's key from the keyring, returning an error on failure. +/// Used by the snapshot-import rollback path, which must surface cleanup +/// failures rather than swallowing them. +pub(crate) fn try_delete_agent_key(pubkey: &str) -> Result<(), String> { + if let Some(store) = agent_secret_store() { + store.delete(&agent_keyring_name(pubkey)) + } else { + // No keyring backend — nothing to clean up. + Ok(()) + } +} + /// Remove an agent's key from the keyring (best-effort). Called when an agent /// is deleted so its secret does not linger in the OS store. pub fn delete_agent_key(pubkey: &str) { - if let Some(store) = agent_secret_store() { - if let Err(e) = store.delete(&agent_keyring_name(pubkey)) { - eprintln!("buzz-desktop: failed to delete agent {pubkey} key from keyring: {e}"); - } + if let Err(e) = try_delete_agent_key(pubkey) { + eprintln!("buzz-desktop: failed to delete agent {pubkey} key from keyring: {e}"); } } @@ -447,14 +688,50 @@ fn bytecount_newlines(buf: &[u8]) -> usize { buf.iter().filter(|&&b| b == b'\n').count() } -pub fn meaningful_agent_error_from_log(path: &Path) -> Option { +/// A meaningful error recovered from an exited agent's log tail. +pub struct AgentLogError { + /// The full log line, wrapped as `Agent reported error…` for display. + pub message: String, + /// JSON-RPC error code parsed from the line's `(code N)` marker, or a + /// synthetic code for known bare prefixes. `None` for legacy-format + /// lines that carry no code (or when the code fails to parse as i64). + pub code: Option, +} + +pub fn meaningful_agent_error_from_log(path: &Path) -> Option { let tail = read_log_tail(path, 200).ok()?; tail.lines().rev().map(str::trim).find_map(|line| { - if line.starts_with("Agent reported error:") { - return Some(line.to_string()); + // New format: "Agent reported error (code -32002): ..." + if let Some(rest) = line.strip_prefix("Agent reported error (code ") { + if let Some(paren_end) = rest.find("): ") { + let code = rest[..paren_end].parse::().ok(); + return Some(AgentLogError { + message: line.to_string(), + code, + }); + } } + // Legacy format (older buzz-acp builds): "Agent reported error: ..." + if line.starts_with("Agent reported error:") { + return Some(AgentLogError { + message: line.to_string(), + code: None, + }); + } + // Bare prefixes emitted by older agent binaries whose Display still leaks + // unwrapped errors. Promote these so they surface instead of the generic + // "harness exited with status N" fallback. if line.starts_with("llm auth:") { - return Some(format!("Agent reported error: {line}")); + return Some(AgentLogError { + message: format!("Agent reported error: {line}"), + code: Some(-32001), + }); + } + if line.starts_with("llm model not found:") { + return Some(AgentLogError { + message: format!("Agent reported error: {line}"), + code: Some(-32002), + }); } None }) @@ -481,6 +758,7 @@ mod tests { fail_verify: bool, stored: RefCell>, write_count: RefCell, + read_count: RefCell, } impl FakeKeyStore { @@ -490,6 +768,7 @@ mod tests { fail_verify: false, stored: RefCell::new(HashMap::new()), write_count: RefCell::new(0), + read_count: RefCell::new(0), } } fn unreachable() -> Self { @@ -498,6 +777,7 @@ mod tests { fail_verify: false, stored: RefCell::new(HashMap::new()), write_count: RefCell::new(0), + read_count: RefCell::new(0), } } fn verify_fails() -> Self { @@ -506,6 +786,7 @@ mod tests { fail_verify: true, stored: RefCell::new(HashMap::new()), write_count: RefCell::new(0), + read_count: RefCell::new(0), } } /// Seed a key as already present in the keyring. @@ -531,8 +812,22 @@ mod tests { if !self.reachable { return Err("keyring backend unreachable".to_string()); } + *self.read_count.borrow_mut() += 1; Ok(self.stored.borrow().get(name).cloned()) } + fn load_all_readonly(&self) -> Result>, String> { + if !self.reachable { + return Err("keyring backend unreachable".to_string()); + } + *self.read_count.borrow_mut() += 1; + let map = self.stored.borrow().clone(); + // Return None when completely empty (simulates no blob written yet). + if map.is_empty() { + Ok(None) + } else { + Ok(Some(map)) + } + } fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String> { if self.fail_verify { return Err("read-back verify failed".to_string()); @@ -543,6 +838,20 @@ mod tests { .insert(name.to_string(), value.to_string()); Ok(()) } + fn store_all(&self, entries: &HashMap) -> Result<(), String> { + if !self.reachable { + return Err("keyring backend unreachable".to_string()); + } + if self.fail_verify { + return Err("read-back verify failed".to_string()); + } + *self.write_count.borrow_mut() += 1; + let mut stored = self.stored.borrow_mut(); + for (k, v) in entries { + stored.insert(k.clone(), v.clone()); + } + Ok(()) + } } fn record_with_key(nsec: &str) -> ManagedAgentRecord { @@ -772,20 +1081,39 @@ mod tests { #[test] fn meaningful_agent_error_from_log_promotes_wrapped_llm_auth() { - let file = write_log("noise\nAgent reported error: llm auth: denied\n"); - assert_eq!( - super::meaningful_agent_error_from_log(file.path()).as_deref(), - Some("Agent reported error: llm auth: denied") + let file = write_log( + "noise\nAgent reported error (code -32001): llm auth: 401 unauthorized: ...\n", ); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert!(result.message.contains("llm auth")); + assert_eq!(result.code, Some(-32001)); } #[test] fn meaningful_agent_error_from_log_promotes_unwrapped_llm_auth() { let file = write_log("noise\nllm auth: denied\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!(result.message, "Agent reported error: llm auth: denied"); + assert_eq!(result.code, Some(-32001)); + } + + #[test] + fn meaningful_agent_error_from_log_promotes_bare_model_not_found() { + let file = write_log("noise\nllm model not found: (some-model) 404\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); assert_eq!( - super::meaningful_agent_error_from_log(file.path()).as_deref(), - Some("Agent reported error: llm auth: denied") + result.message, + "Agent reported error: llm model not found: (some-model) 404" ); + assert_eq!(result.code, Some(-32002)); + } + + #[test] + fn meaningful_agent_error_from_log_promotes_legacy_format() { + let file = write_log("noise\nAgent reported error: llm: 500 internal\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!(result.message, "Agent reported error: llm: 500 internal"); + assert_eq!(result.code, None); } #[test] @@ -802,4 +1130,205 @@ mod tests { "2026-05-27T15:16:32 INFO buzz_acp: starting" ); } + + // ── keyring-dev-migration tests ──────────────────────────────────────── + + #[test] + fn copy_agent_keys_copies_keys_present_in_src_to_dst() { + // Keys in src but not in dst must be copied in a single bulk write, + // and the migration-complete marker must be set. + let src = FakeKeyStore::reachable() + .with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha") + .with_key(&agent_keyring_name("agent-beta"), "nsec1beta"); + let dst = FakeKeyStore::reachable(); + + super::copy_agent_keys_between_stores( + &["agent-alpha".to_string(), "agent-beta".to_string()], + &src, + &dst, + ); + + assert_eq!( + dst.stored + .borrow() + .get(&agent_keyring_name("agent-alpha")) + .map(String::as_str), + Some("nsec1alpha"), + "agent-alpha must be copied from src to dst" + ); + assert_eq!( + dst.stored + .borrow() + .get(&agent_keyring_name("agent-beta")) + .map(String::as_str), + Some("nsec1beta"), + "agent-beta must be copied from src to dst" + ); + assert_eq!( + dst.stored + .borrow() + .get(super::DEV_MIGRATION_MARKER) + .map(String::as_str), + Some("done"), + "migration-complete marker must be set after first migration" + ); + // Bulk write: exactly 1 store_all call. + assert_eq!( + *dst.write_count.borrow(), + 1, + "must perform exactly one bulk write" + ); + // Src accessed exactly once (bulk blob read). + assert_eq!( + *src.read_count.borrow(), + 1, + "src must be read exactly once (bulk)" + ); + } + + #[test] + fn copy_agent_keys_skips_keys_already_in_dst() { + // Idempotency: a key already present in dst must NOT be overwritten + // — the agent may have rotated their key in the dev service. + let src = + FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1old"); + let dst = + FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1new"); + + super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst); + + // dst value must remain unchanged — src must not overwrite it. + assert_eq!( + dst.stored + .borrow() + .get(&agent_keyring_name("agent-alpha")) + .map(String::as_str), + Some("nsec1new"), + "key already in dst must not be overwritten by migration" + ); + // Marker must still be written even though no new keys were copied. + assert_eq!( + dst.stored + .borrow() + .get(super::DEV_MIGRATION_MARKER) + .map(String::as_str), + Some("done"), + "marker must be set even when all keys are already present" + ); + assert_eq!(*src.read_count.borrow(), 0); + } + + #[test] + fn copy_agent_keys_skips_keys_absent_from_src() { + // A pubkey with no entry in src (new agent that will mint a fresh key) + // must be silently skipped — no agent key written to dst. + let src = FakeKeyStore::reachable(); // empty + let dst = FakeKeyStore::reachable(); + + super::copy_agent_keys_between_stores(&["new-agent".to_string()], &src, &dst); + + assert!( + dst.stored + .borrow() + .get(&agent_keyring_name("new-agent")) + .is_none(), + "absent src key must produce no agent key write to dst" + ); + // Marker must still be written. + assert_eq!( + dst.stored + .borrow() + .get(super::DEV_MIGRATION_MARKER) + .map(String::as_str), + Some("done"), + "marker must be set even when no keys were present in src" + ); + } + + #[test] + fn copy_agent_keys_skips_all_when_dst_unreachable() { + // When dst keyring is unreachable the migration must be a no-op — never + // data-loss (failing to write is fine; the agent will re-mint on next + // onboarding run). + let src = + FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha"); + let dst = FakeKeyStore::unreachable(); + + super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst); + + // No writes attempted to an unreachable dst. + assert_eq!(*dst.write_count.borrow(), 0); + // Src must not have been accessed (failed on dst read, returned early). + assert_eq!( + *src.read_count.borrow(), + 0, + "src must not be accessed when dst is unreachable" + ); + } + + #[test] + fn copy_agent_keys_skips_entirely_when_marker_present() { + // After the first migration, the marker is in dst. Subsequent calls + // must return immediately — the prod keyring (src) must never be read. + let src = + FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha"); + let dst = FakeKeyStore::reachable() + .with_key(super::DEV_MIGRATION_MARKER, "done") + .with_key(&agent_keyring_name("agent-alpha"), "nsec1dev"); + + super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst); + + // Src must not have been accessed at all. + assert_eq!( + *src.read_count.borrow(), + 0, + "src must not be read when migration-complete marker is present" + ); + // Dst must not have been written. + assert_eq!( + *dst.write_count.borrow(), + 0, + "dst must not be written when migration-complete marker is present" + ); + // Dev key must remain unchanged. + assert_eq!( + dst.stored + .borrow() + .get(&agent_keyring_name("agent-alpha")) + .map(String::as_str), + Some("nsec1dev"), + "dev key must not be overwritten on subsequent boots" + ); + } + + #[test] + fn copy_agent_keys_writes_marker_even_with_empty_agent_list() { + // An empty pubkey list (no agents yet) must still write the marker so + // future boots skip the prod read. + let src = FakeKeyStore::reachable(); + let dst = FakeKeyStore::reachable(); + + super::copy_agent_keys_between_stores(&[], &src, &dst); + + assert_eq!( + dst.stored + .borrow() + .get(super::DEV_MIGRATION_MARKER) + .map(String::as_str), + Some("done"), + "marker must be set even when pubkey list is empty" + ); + assert_eq!(*src.read_count.borrow(), 0); + } + + #[test] + fn try_delete_agent_key_returns_result() { + // Verify the result-returning seam exists and has the correct signature. + // We cannot call it in default builds (system-keyring feature is on, + // which accesses the real OS keychain and blocks in headless/CI). The + // real keychain paths are integration-tested through the #[ignore] + // tests in secret_store.rs; the rollback aggregation is tested in + // team_snapshot::tests::rollback_aggregates_multiple_errors. + let _: fn(&str) -> Result<(), String> = super::try_delete_agent_key; + } } diff --git a/desktop/src-tauri/src/managed_agents/team_events.rs b/desktop/src-tauri/src/managed_agents/team_events.rs index b0395f15b4..a6f7c506c7 100644 --- a/desktop/src-tauri/src/managed_agents/team_events.rs +++ b/desktop/src-tauri/src/managed_agents/team_events.rs @@ -24,6 +24,8 @@ pub struct TeamEventContent { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub persona_ids: Vec, } @@ -35,6 +37,7 @@ pub fn team_event_content(record: &TeamRecord) -> TeamEventContent { TeamEventContent { name: record.name.clone(), description: record.description.clone(), + instructions: record.instructions.clone(), persona_ids: record.persona_ids.clone(), } } @@ -86,6 +89,7 @@ mod tests { id: "team-123".to_string(), name: "Test Team".to_string(), description: Some("A test team".to_string()), + instructions: Some("Coordinate carefully.".to_string()), persona_ids: vec!["p1".to_string(), "p2".to_string()], is_builtin: false, source_dir: Some(PathBuf::from("/local/only/path")), @@ -130,6 +134,7 @@ mod tests { // Published fields present. assert!(json.contains("\"name\"")); assert!(json.contains("\"persona_ids\"")); + assert!(json.contains("\"instructions\"")); // Local-only / install-specific fields never published. assert!(!json.contains("source_dir")); assert!(!json.contains("is_symlink")); diff --git a/desktop/src-tauri/src/managed_agents/team_repair.rs b/desktop/src-tauri/src/managed_agents/team_repair.rs index 48d9fdddf4..6420792109 100644 --- a/desktop/src-tauri/src/managed_agents/team_repair.rs +++ b/desktop/src-tauri/src/managed_agents/team_repair.rs @@ -1,19 +1,4 @@ -//! One-time data repair for teams created before PR 852. -//! -//! Backfills `TeamRecord.source_dir` and deduplicates `PersonaRecord`s that -//! share a `(source_team, source_team_persona_slug)` pair — the result of -//! repeated imports before the matching predicate was fixed. -//! -//! All repairs touch only JSON records; the symlinked source directory is read -//! but never written. - -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::Path; - -use tauri::AppHandle; - -use crate::managed_agents::{ManagedAgentRecord, PersonaRecord, TeamRecord}; +use crate::managed_agents::TeamRecord; /// Derive the shared key used to match personas to a team. For directory- /// backed teams this is the directory name (the pack manifest ID); for others @@ -31,234 +16,18 @@ pub fn team_persona_key(team: &TeamRecord) -> &str { .unwrap_or(&team.id) } -/// Backfill `TeamRecord.source_dir` for directory-backed teams created before -/// the field existed. Scans team persona_ids, finds the `source_team` value -/// (manifest ID), checks if that directory exists under `teams_dir`, and sets -/// `team.source_dir` to that path. Respects symlinks (reads but never writes). -/// -/// Returns `true` if any team was modified. -pub(super) fn backfill_source_dirs( - teams: &mut [TeamRecord], - personas: &[PersonaRecord], - teams_dir: &Path, -) -> bool { - let mut changed = false; - - for team in teams.iter_mut() { - if team.is_builtin || team.source_dir.as_ref().is_some_and(|d| d.exists()) { - continue; - } - - // The directory name the team's personas point at. All personas of one - // team share a single source_team value (the manifest ID). - let Some(dir_name) = team - .persona_ids - .iter() - .find_map(|id| personas.iter().find(|p| p.id == *id)) - .and_then(|p| p.source_team.clone()) - else { - continue; - }; - - let candidate = teams_dir.join(&dir_name); - if candidate.exists() { - team.is_symlink = fs::symlink_metadata(&candidate) - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false); - team.symlink_target = if team.is_symlink { - fs::canonicalize(&candidate) - .ok() - .map(|p| p.display().to_string()) - } else { - None - }; - team.source_dir = Some(candidate); - changed = true; - } - } - - changed -} - -/// Deduplicate `PersonaRecord`s that share a `(source_team, source_team_persona_slug)` -/// pair — the result of repeated imports before the matching predicate was fixed. -/// -/// The winner is chosen by `updated_at` descending, then `id` ascending. Loser -/// IDs are repointed to the winner in `TeamRecord.persona_ids` and -/// `ManagedAgentRecord.persona_id`, then the losers are dropped. -/// -/// After dropping losers, performs a self-healing scrub: any reference in -/// `team.persona_ids` or `agent.persona_id` that points to a persona ID not -/// in the surviving set is removed. This makes the migration convergent under -/// any crash interleaving without a transaction. -/// -/// Returns `true` if anything changed. -pub(super) fn dedup_personas( - personas: &mut Vec, - teams: &mut [TeamRecord], - agents: &mut [ManagedAgentRecord], -) -> bool { - // Group indices by (source_team, slug); only personas with both set collide. - let mut groups: HashMap<(String, String), Vec> = HashMap::new(); - for (idx, p) in personas.iter().enumerate() { - if let (Some(team), Some(slug)) = (&p.source_team, &p.source_team_persona_slug) { - groups - .entry((team.clone(), slug.clone())) - .or_default() - .push(idx); - } - } - - // loser_id -> winner_id - let mut remap: HashMap = HashMap::new(); - for indices in groups.values() { - if indices.len() < 2 { - continue; - } - let mut ranked: Vec = indices.clone(); - ranked.sort_by(|&a, &b| { - personas[b] - .updated_at - .cmp(&personas[a].updated_at) - .then_with(|| personas[a].id.cmp(&personas[b].id)) - }); - let winner_id = personas[ranked[0]].id.clone(); - for &loser_idx in &ranked[1..] { - let loser_id = personas[loser_idx].id.clone(); - eprintln!("dedup: persona {loser_id} merged into {winner_id}"); - remap.insert(loser_id, winner_id.clone()); - } - } - - // Repoint references before dropping losers. - if !remap.is_empty() { - for team in teams.iter_mut() { - let mut seen = HashSet::new(); - team.persona_ids = std::mem::take(&mut team.persona_ids) - .into_iter() - .map(|id| remap.get(&id).cloned().unwrap_or(id)) - .filter(|id| seen.insert(id.clone())) - .collect(); - } - for agent in agents.iter_mut() { - if let Some(id) = &agent.persona_id { - if let Some(winner) = remap.get(id) { - agent.persona_id = Some(winner.clone()); - } - } - } - - personas.retain(|p| !remap.contains_key(&p.id)); - } - - // Self-healing scrub: remove any references that point to persona IDs not - // in the surviving set. This handles the crash-window case where a prior - // partial run dropped losers from personas.json but never repointed the - // references in teams.json / managed-agents.json. - let surviving_ids: HashSet<&str> = personas.iter().map(|p| p.id.as_str()).collect(); - let mut scrubbed = false; - - for team in teams.iter_mut() { - let before_len = team.persona_ids.len(); - team.persona_ids - .retain(|id| surviving_ids.contains(id.as_str())); - if team.persona_ids.len() != before_len { - eprintln!( - "dedup: scrubbed {} dangling persona_ids from team {}", - before_len - team.persona_ids.len(), - team.id - ); - scrubbed = true; - } - } - for agent in agents.iter_mut() { - if let Some(id) = &agent.persona_id { - if !surviving_ids.contains(id.as_str()) { - eprintln!( - "dedup: scrubbed dangling persona_id {} from agent {}", - id, agent.name - ); - agent.persona_id = None; - scrubbed = true; - } - } - } - - !remap.is_empty() || scrubbed -} - -/// Sync all directory-backed teams on launch — the team equivalent of the -/// former `sync_pack_personas`. Runs the one-time backfill + dedup repair, -/// then re-syncs each team from its source directory. Silently skips teams -/// whose source directory is missing (e.g., external drive unmounted). -pub fn sync_team_personas(app: &AppHandle) -> Result<(), String> { - use super::teams::{load_teams, save_teams, sync_team_from_dir, teams_dir}; - - // One-time data repair: backfill source_dir for legacy teams and deduplicate - // personas that were imported multiple times before the predicate fix. - let teams_base = teams_dir(app)?; - let mut teams = load_teams(app)?; - let mut personas = super::load_personas(app)?; - let mut agents = super::load_managed_agents(app)?; - - let backfilled = backfill_source_dirs(&mut teams, &personas, &teams_base); - let deduped = dedup_personas(&mut personas, &mut teams, &mut agents); - - // Write reference holders (teams, agents) BEFORE the personas they point at. - // The three saves are individually atomic but not transactional together; a - // crash before save_personas then leaves references aimed at personas that - // still exist (over-pointing), never dangling. dedup_personas is also - // self-healing, so the next launch converges under any crash interleaving. - if backfilled || deduped { - save_teams(app, &teams)?; - } - if deduped { - super::save_managed_agents(app, &agents)?; - super::save_personas(app, &personas)?; - } - - for team in &teams { - if team.source_dir.as_ref().is_some_and(|d| d.exists()) { - if let Err(e) = sync_team_from_dir(app, &team.id) { - eprintln!("buzz-desktop: sync team {}: {e}", team.id); - } - } - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; - use crate::managed_agents::{ManagedAgentRecord, PersonaRecord, TeamRecord}; + use crate::managed_agents::TeamRecord; use std::path::PathBuf; - use tempfile::TempDir; - - fn persona(id: &str, source_team: Option<&str>, slug: Option<&str>) -> PersonaRecord { - PersonaRecord { - id: id.to_string(), - display_name: id.to_string(), - avatar_url: None, - system_prompt: String::new(), - runtime: None, - model: None, - provider: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - source_team: source_team.map(|s| s.to_string()), - source_team_persona_slug: slug.map(|s| s.to_string()), - env_vars: Default::default(), - created_at: "2025-01-01T00:00:00Z".to_string(), - updated_at: "2025-01-01T00:00:00Z".to_string(), - } - } fn team(id: &str) -> TeamRecord { TeamRecord { id: id.to_string(), name: id.to_string(), description: None, + instructions: None, persona_ids: Vec::new(), is_builtin: false, source_dir: None, @@ -270,49 +39,6 @@ mod tests { } } - fn agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: String::new(), - name: name.to_string(), - persona_id: persona_id.map(|s| s.to_string()), - private_key_nsec: String::new(), - auth_tag: None, - relay_url: String::new(), - avatar_url: None, - acp_command: String::new(), - agent_command: String::new(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 0, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - mcp_toolsets: None, - start_on_app_launch: false, - runtime_pid: None, - backend: Default::default(), - backend_agent_id: None, - provider_binary_path: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: String::new(), - updated_at: String::new(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - respond_to: Default::default(), - respond_to_allowlist: vec![], - env_vars: std::collections::BTreeMap::new(), - relay_mesh: None, - } - } - // ── team_persona_key ───────────────────────────────────────────────── #[test] @@ -327,169 +53,4 @@ mod tests { let t = team("builtin-team:fizz"); assert_eq!(team_persona_key(&t), "builtin-team:fizz"); } - - // ── backfill_source_dirs ───────────────────────────────────────────── - - #[test] - fn backfill_sets_source_dir_for_legacy_uuid_team() { - let tmp = TempDir::new().unwrap(); - let teams_dir = tmp.path(); - std::fs::create_dir(teams_dir.join("com.test.pack")).unwrap(); - - let mut teams = vec![{ - let mut t = team("uuid-123"); - t.persona_ids = vec!["p1".to_string()]; - t - }]; - let personas = vec![{ - let mut p = persona("p1", Some("com.test.pack"), Some("scout")); - p.source_team = Some("com.test.pack".to_string()); - p - }]; - - let changed = backfill_source_dirs(&mut teams, &personas, teams_dir); - assert!(changed); - let key = team_persona_key(&teams[0]); - assert_eq!(key, "com.test.pack"); - assert_eq!(teams[0].source_dir, Some(teams_dir.join("com.test.pack"))); - } - - #[test] - fn backfill_skips_when_directory_absent() { - let tmp = TempDir::new().unwrap(); - let teams_dir = tmp.path(); - // Do NOT create the directory - - let mut teams = vec![{ - let mut t = team("uuid-123"); - t.persona_ids = vec!["p1".to_string()]; - t - }]; - let personas = vec![persona("p1", Some("com.test.pack"), Some("scout"))]; - - let changed = backfill_source_dirs(&mut teams, &personas, teams_dir); - assert!(!changed); - assert!(teams[0].source_dir.is_none()); - } - - #[test] - fn backfill_skips_builtin_and_already_set_teams() { - let tmp = TempDir::new().unwrap(); - let teams_dir = tmp.path(); - std::fs::create_dir(teams_dir.join("com.test.pack")).unwrap(); - - let mut builtin = team("builtin-team:fizz"); - builtin.is_builtin = true; - builtin.persona_ids = vec!["p1".to_string()]; - - let mut already_set = team("uuid-456"); - already_set.source_dir = Some(teams_dir.join("com.test.pack")); - already_set.persona_ids = vec!["p2".to_string()]; - - let mut teams = vec![builtin, already_set]; - let personas = vec![ - persona("p1", Some("com.test.pack"), Some("scout")), - persona("p2", Some("com.test.pack"), Some("kit")), - ]; - - let changed = backfill_source_dirs(&mut teams, &personas, teams_dir); - assert!(!changed); - } - - // ── dedup_personas ─────────────────────────────────────────────────── - - #[test] - fn dedup_keeps_newest_and_repoints_references() { - let mut p_old = persona("old-id", Some("team-a"), Some("scout")); - p_old.updated_at = "2025-01-01T00:00:00Z".to_string(); - let mut p_new = persona("new-id", Some("team-a"), Some("scout")); - p_new.updated_at = "2025-06-01T00:00:00Z".to_string(); - - let mut personas = vec![p_old, p_new]; - let mut teams = vec![{ - let mut t = team("t1"); - t.persona_ids = vec!["old-id".to_string(), "new-id".to_string()]; - t - }]; - let mut agents = vec![agent("agent-1", Some("old-id"))]; - - let changed = dedup_personas(&mut personas, &mut teams, &mut agents); - assert!(changed); - assert_eq!(personas.len(), 1); - assert_eq!(personas[0].id, "new-id"); - assert_eq!(teams[0].persona_ids, vec!["new-id"]); - assert_eq!(agents[0].persona_id, Some("new-id".to_string())); - } - - #[test] - fn dedup_breaks_ties_by_id_when_updated_at_equal() { - let p_a = persona("aaa", Some("team-a"), Some("scout")); - let p_b = persona("bbb", Some("team-a"), Some("scout")); - - let mut personas = vec![p_a, p_b]; - let mut teams = vec![{ - let mut t = team("t1"); - t.persona_ids = vec!["aaa".to_string(), "bbb".to_string()]; - t - }]; - let mut agents = vec![]; - - let changed = dedup_personas(&mut personas, &mut teams, &mut agents); - assert!(changed); - assert_eq!(personas.len(), 1); - // "aaa" < "bbb" lexically, so "aaa" wins the tiebreak - assert_eq!(personas[0].id, "aaa"); - } - - #[test] - fn dedup_is_noop_without_duplicates() { - let mut personas = vec![ - persona("p1", Some("team-a"), Some("scout")), - persona("p2", Some("team-a"), Some("kit")), - ]; - let mut teams = vec![]; - let mut agents = vec![]; - - let changed = dedup_personas(&mut personas, &mut teams, &mut agents); - assert!(!changed); - assert_eq!(personas.len(), 2); - } - - #[test] - fn dedup_ignores_personas_without_source_team() { - let mut personas = vec![ - persona("p1", None, Some("scout")), - persona("p2", None, Some("scout")), - ]; - let mut teams = vec![]; - let mut agents = vec![]; - - let changed = dedup_personas(&mut personas, &mut teams, &mut agents); - assert!(!changed); - assert_eq!(personas.len(), 2); - } - - #[test] - fn dedup_heals_dangling_agent_reference_from_prior_crash() { - // Simulate the crash-window scenario: a prior run dropped the loser - // persona from personas.json but never repointed managed-agents.json. - // On this launch, only the winner survives. The dangling reference - // must be scrubbed. - let winner = persona("winner-id", Some("team-a"), Some("scout")); - // The loser is already gone (dropped in prior crash-interrupted run). - let mut personas = vec![winner]; - let mut teams = vec![{ - let mut t = team("t1"); - // team still references the loser (stale from prior crash) - t.persona_ids = vec!["winner-id".to_string(), "loser-id".to_string()]; - t - }]; - let mut agents = vec![agent("agent-1", Some("loser-id"))]; - - let changed = dedup_personas(&mut personas, &mut teams, &mut agents); - // The self-healing scrub should have removed the dangling references - assert!(changed); - assert_eq!(teams[0].persona_ids, vec!["winner-id"]); - assert_eq!(agents[0].persona_id, None); - } } diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs new file mode 100644 index 0000000000..d88a362723 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -0,0 +1,588 @@ +//! `buzz-team-snapshot v1` — manifest type, encoder, and decoder. +//! +//! A team snapshot is a portable, shareable representation of a team: its +//! header (name, description) plus a `members` array where each member +//! reuses the existing `AgentSnapshot` type from `agent_snapshot.rs`. +//! +//! Two encodings: +//! - `.team.json` — canonical; supports memory when selected. +//! - `.team.png` — 1×1 placeholder PNG with manifest in a `buzz_team_snapshot` +//! tEXt chunk; supports memory when selected. Since `TeamRecord` has no +//! team-level avatar, the image body is always the 1×1 placeholder; member +//! avatars are carried in each `AgentSnapshot.profile.avatar_data_url`. +//! +//! **Old `.team.json` files** (flat `{version:1, type:"team", …}` schema) carry +//! no `format` discriminator. The caller's legacy-reject path handles them; this +//! decoder never sees them. +//! +//! **ZIP is NOT in v1** — consistent with the agent-snapshot policy. +//! +//! # Secret exclusion +//! +//! Per-member exclusions are inherited from `AgentSnapshot` — secrets are +//! excluded by construction in `agent_snapshot::build_snapshot`. No +//! team-level secrets are introduced by this wrapper. +//! +//! # Memory-consistency invariant +//! +//! Any member with `memory.level == None` and non-empty `memory.entries` is +//! malformed. `validate_member_memory_consistency` enforces this on both +//! decode paths (JSON via `validate_team_snapshot`, PNG via +//! `decode_team_snapshot_png` → JSON round-trip) so the rule is single-sourced. + +// Items are `pub(crate)` for P4.2 callers; suppress dead-code lint until then. +#![allow(dead_code)] + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use png::Decoder; +use serde::{Deserialize, Serialize}; +use std::io::Cursor; + +use crate::managed_agents::{ + agent_snapshot::{make_png_with_text, validate_snapshot, AgentSnapshot, MemoryLevel}, + TeamRecord, +}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/// tEXt chunk keyword used in `.team.png` files. +pub const PNG_CHUNK_KEYWORD: &str = "buzz_team_snapshot"; + +/// Format discriminator — used for sniffing and validation. +pub const FORMAT_DISCRIMINATOR: &str = "buzz-team-snapshot"; + +/// Version of the manifest format produced by this module. +pub const FORMAT_VERSION: u32 = 1; + +// ── Manifest sub-types ──────────────────────────────────────────────────────── + +/// Team-level metadata carried in the snapshot header. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshotMeta { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, +} + +// ── Top-level manifest ──────────────────────────────────────────────────────── + +/// The top-level `buzz-team-snapshot v1` manifest. +/// +/// Serializes to / from JSON. Embedded in `.team.json` directly, or in the +/// `buzz_team_snapshot` tEXt chunk of a `.team.png` (base64-encoded). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TeamSnapshot { + /// Fixed discriminator for format sniffing. + pub format: String, + /// Schema version. This module produces version 1. + pub version: u32, + /// Team-level metadata. + pub team: TeamSnapshotMeta, + /// One `AgentSnapshot` per team member. Order is preserved. + pub members: Vec, +} + +// ── Builder ─────────────────────────────────────────────────────────────────── + +/// Construct a `TeamSnapshot` from a `TeamRecord` and pre-built member snapshots. +/// +/// Members are pre-built by the caller via `agent_snapshot::build_snapshot` — +/// this function does not call into Tauri or perform I/O. Same purity contract +/// as `build_snapshot`: pure assembly, deterministic, testable without AppHandle. +pub fn build_team_snapshot(team: &TeamRecord, members: Vec) -> TeamSnapshot { + TeamSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + team: TeamSnapshotMeta { + name: team.name.clone(), + description: team.description.clone(), + instructions: team.instructions.clone(), + }, + members, + } +} + +// ── JSON encoding / decoding ────────────────────────────────────────────────── + +/// Encode the manifest to pretty-printed JSON bytes. +pub fn encode_team_snapshot_json(snapshot: &TeamSnapshot) -> Result, String> { + serde_json::to_vec_pretty(snapshot) + .map_err(|e| format!("Failed to serialize team snapshot: {e}")) +} + +/// Decode a manifest from JSON bytes. +pub fn decode_team_snapshot_json(bytes: &[u8]) -> Result { + let snapshot: TeamSnapshot = + serde_json::from_slice(bytes).map_err(|e| format!("Invalid team snapshot JSON: {e}"))?; + validate_team_snapshot(&snapshot)?; + Ok(snapshot) +} + +// ── PNG encoding / decoding ─────────────────────────────────────────────────── + +/// Encode a team snapshot into a `.team.png`. +/// +/// The image body is a 1×1 transparent placeholder — `TeamRecord` has no +/// team-level avatar; each member's avatar lives in their own +/// `AgentSnapshot.profile.avatar_data_url`. The manifest is embedded in the +/// `buzz_team_snapshot` tEXt chunk (base64-encoded JSON). +pub fn encode_team_snapshot_png(snapshot: &TeamSnapshot) -> Result, String> { + // Memory-consistency: reject level=None + non-empty entries (malformed). + for (i, member) in snapshot.members.iter().enumerate() { + validate_member_memory_consistency(i, member)?; + } + + let json_bytes = encode_team_snapshot_json(snapshot)?; + let chunk_text = STANDARD.encode(&json_bytes); + + // No team-level avatar: always use the 1×1 placeholder. + // Each member's avatar is carried within their own AgentSnapshot. + make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text) +} + +/// Decode a manifest from a `.team.png` tEXt chunk. +pub fn decode_team_snapshot_png(png_bytes: &[u8]) -> Result { + let decoder = Decoder::new(Cursor::new(png_bytes)); + let reader = decoder + .read_info() + .map_err(|e| format!("Invalid PNG: {e}"))?; + let info = reader.info(); + + let chunk_text = info + .uncompressed_latin1_text + .iter() + .find(|c| c.keyword == PNG_CHUNK_KEYWORD) + .map(|c| c.text.as_str()) + .ok_or_else(|| "PNG does not contain a buzz_team_snapshot tEXt chunk".to_string())?; + + let json_bytes = STANDARD + .decode(chunk_text.trim()) + .map_err(|e| format!("Invalid base64 in PNG chunk: {e}"))?; + + let snapshot = decode_team_snapshot_json(&json_bytes)?; + Ok(snapshot) +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +/// Assert that a member's memory section is internally consistent. +/// +/// Rejects `memory.level == None` with non-empty `memory.entries` — this is a +/// malformed state that the builder can never produce but a crafted payload +/// could. Single-sourced so the same rule applies on both the PNG encode path +/// and the JSON decode path. +fn validate_member_memory_consistency(idx: usize, member: &AgentSnapshot) -> Result<(), String> { + if member.memory.level == MemoryLevel::None && !member.memory.entries.is_empty() { + return Err(format!( + "member {idx} ({:?}) has memory.level 'none' but non-empty entries — \ + this is a malformed snapshot", + member.definition.name + )); + } + Ok(()) +} + +/// Validate that the manifest has the correct format/version and required +/// fields. Returns an error string on failure. +/// +/// Calls `agent_snapshot::validate_snapshot` for each member so the per-member +/// contract (non-empty name, correct format/version) is enforced at decode time. +/// Also checks the memory-consistency invariant per member via +/// `validate_member_memory_consistency`. +pub(crate) fn validate_team_snapshot(snapshot: &TeamSnapshot) -> Result<(), String> { + if snapshot.format != FORMAT_DISCRIMINATOR { + return Err(format!( + "Unsupported team snapshot format: {:?} (expected {:?})", + snapshot.format, FORMAT_DISCRIMINATOR + )); + } + if snapshot.version != 1 { + return Err(format!( + "Unsupported team snapshot version: {} (expected 1)", + snapshot.version + )); + } + if snapshot.team.name.trim().is_empty() { + return Err("Team snapshot team.name is empty".to_string()); + } + if snapshot.members.is_empty() { + return Err("Team snapshot must have at least one member".to_string()); + } + for (i, member) in snapshot.members.iter().enumerate() { + validate_snapshot(member).map_err(|e| format!("Team member {i} is invalid: {e}"))?; + validate_member_memory_consistency(i, member) + .map_err(|e| format!("Team member {i} memory is malformed: {e}"))?; + } + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::{ + agent_snapshot::{build_snapshot, AgentSnapshotMemory, AgentSnapshotMemoryEntry}, + types::{BackendKind, ManagedAgentRecord, RespondTo}, + }; + use std::collections::BTreeMap; + + /// Build a minimal `TeamRecord` for testing. + fn team_record(name: &str) -> TeamRecord { + TeamRecord { + id: format!("{name}-id"), + name: name.to_string(), + description: Some(format!("{name} description")), + instructions: None, + persona_ids: vec![], + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + } + } + + /// Build a minimal `ManagedAgentRecord` for use as a team member. + fn agent_record(name: &str) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: format!("{name}-pubkey"), + name: name.to_string(), + display_name: Some(format!("{name} Display")), + persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear + private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear + auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear + relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear + avatar_url: Some(format!("https://example.com/{name}.png")), + acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear + agent_command: "goose".to_string(), // MUST NOT appear + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 120, + idle_timeout_seconds: Some(30), + max_turn_duration_seconds: Some(600), + parallelism: 1, + system_prompt: Some(format!("You are {name}.")), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + persona_source_version: None, + env_vars: { + let mut m = BTreeMap::new(); + m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear + m + }, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + slug: Some(name.to_string()), + runtime: Some("goose".to_string()), + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: Some("SENTINEL_SOURCE_TEAM".to_string()), // MUST NOT appear + source_team_persona_slug: Some("SENTINEL_SLUG".to_string()), // MUST NOT appear + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + relay_mesh: None, + } + } + + /// Build a canonical two-member team snapshot with no memory. + fn two_member_team() -> TeamSnapshot { + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let bob = build_snapshot(&agent_record("Bob"), MemoryLevel::None, vec![], None); + build_team_snapshot(&team_record("My Team"), vec![alice, bob]) + } + + // ── Round-trip tests ────────────────────────────────────────────────────── + + #[test] + fn json_round_trip_no_memory() { + let snapshot = two_member_team(); + let bytes = encode_team_snapshot_json(&snapshot).unwrap(); + let parsed = decode_team_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); + } + + #[test] + fn png_round_trip_no_memory() { + let snapshot = two_member_team(); + let png_bytes = encode_team_snapshot_png(&snapshot).unwrap(); + assert!(png_bytes.starts_with(b"\x89PNG"), "output must be a PNG"); + let parsed = decode_team_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.team.name, snapshot.team.name); + assert_eq!(parsed.members.len(), 2); + assert_eq!(parsed.members[0].definition.name, "Alice Display"); + assert_eq!(parsed.members[1].definition.name, "Bob Display"); + } + + // ── PNG memory guard ────────────────────────────────────────────────────── + + #[test] + fn png_round_trip_with_member_memory() { + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "private notes".to_string(), + }]; + let bob = build_snapshot(&agent_record("Bob"), MemoryLevel::Everything, entries, None); + let snapshot = build_team_snapshot(&team_record("Team"), vec![alice, bob]); + + let png_bytes = encode_team_snapshot_png(&snapshot).unwrap(); + assert!(png_bytes.starts_with(b"\x89PNG"), "output must be a PNG"); + let parsed = decode_team_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.members.len(), 2); + assert_eq!(parsed.members[1].memory.level, MemoryLevel::Everything); + assert_eq!(parsed.members[1].memory.entries.len(), 1); + assert_eq!(parsed.members[1].memory.entries[0].slug, "mem/notes"); + } + + #[test] + fn png_export_rejected_when_none_level_with_nonempty_entries() { + // Inconsistent state: level == None but entries non-empty on a member. + // Both the PNG encoder and JSON decoder must reject this via the shared + // validate_member_memory_consistency helper. + let mut alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + alice.memory = AgentSnapshotMemory { + level: MemoryLevel::None, + entries: vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked".to_string(), + }], + }; + // PNG path + let snapshot = build_team_snapshot(&team_record("Team"), vec![alice.clone()]); + assert!( + encode_team_snapshot_png(&snapshot).is_err(), + "PNG encoder must reject member with level=None + non-empty entries" + ); + // JSON decode path: craft the JSON manually and decode it + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!( + result.is_err(), + "JSON decoder must also reject member with level=None + non-empty entries" + ); + } + + // ── Validation tests ────────────────────────────────────────────────────── + + #[test] + fn validate_rejects_wrong_format() { + let mut snapshot = two_member_team(); + snapshot.format = "not-a-team-snapshot".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Unsupported team snapshot format")); + } + + #[test] + fn validate_rejects_wrong_version() { + let mut snapshot = two_member_team(); + snapshot.version = 99; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Unsupported team snapshot version")); + } + + #[test] + fn validate_rejects_empty_team_name() { + let mut snapshot = two_member_team(); + snapshot.team.name = " ".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("team.name is empty")); + } + + #[test] + fn validate_rejects_zero_members() { + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let mut snapshot = build_team_snapshot(&team_record("Team"), vec![alice]); + snapshot.members.clear(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("at least one member")); + } + + #[test] + fn validate_rejects_member_with_wrong_format() { + let mut snapshot = two_member_team(); + snapshot.members[0].format = "not-an-agent".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("is invalid")); + } + + // ── Secret exclusion tests (wrapper-level) ──────────────────────────────── + // + // Per-member exclusions are already proven in agent_snapshot::tests. + // These tests assert the team wrapper does not re-introduce any secret + // field on top of the per-member snapshots. + + fn team_json_string() -> String { + let bytes = encode_team_snapshot_json(&two_member_team()).unwrap(); + String::from_utf8(bytes).unwrap() + } + + #[test] + fn wrapper_does_not_introduce_secret_fields() { + let json = team_json_string(); + assert!(!json.contains("nsec1secret"), "nsec must not appear"); + assert!( + !json.contains("auth-tag-secret"), + "auth_tag value must not appear" + ); + assert!(!json.contains("API_KEY"), "env var key must not appear"); + assert!(!json.contains("secret123"), "env var value must not appear"); + assert!( + !json.contains("wss://relay.example.com"), + "relay_url must not appear" + ); + assert!( + !json.contains("SENTINEL_SOURCE_TEAM"), + "source_team must not appear" + ); + assert!( + !json.contains("SENTINEL_SLUG"), + "source_team_persona_slug must not appear" + ); + assert!( + !json.contains("SENTINEL_PERSONA_ID"), + "persona_id must not appear" + ); + } + + // ── Structural / shape tests ────────────────────────────────────────────── + + #[test] + fn member_order_is_preserved() { + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let bob = build_snapshot(&agent_record("Bob"), MemoryLevel::None, vec![], None); + let carol = build_snapshot(&agent_record("Carol"), MemoryLevel::None, vec![], None); + let snapshot = build_team_snapshot(&team_record("Ordered Team"), vec![alice, bob, carol]); + + let bytes = encode_team_snapshot_json(&snapshot).unwrap(); + let parsed = decode_team_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed.members[0].definition.name, "Alice Display"); + assert_eq!(parsed.members[1].definition.name, "Bob Display"); + assert_eq!(parsed.members[2].definition.name, "Carol Display"); + } + + #[test] + fn description_absent_when_none() { + let mut record = team_record("No Desc"); + record.description = None; + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let snapshot = build_team_snapshot(&record, vec![alice]); + let bytes = encode_team_snapshot_json(&snapshot).unwrap(); + let parsed = decode_team_snapshot_json(&bytes).unwrap(); + assert!(parsed.team.description.is_none()); + let json = String::from_utf8(bytes).unwrap(); + assert!( + !json.contains("\"description\""), + "absent description must not serialize" + ); + } + + #[test] + fn format_and_version_correct_in_output() { + let json = team_json_string(); + assert!( + json.contains("\"buzz-team-snapshot\""), + "format discriminator must be present" + ); + assert!(json.contains("\"version\": 1"), "version must be 1"); + } + + #[test] + fn build_preserves_team_metadata() { + let snapshot = two_member_team(); + assert_eq!(snapshot.team.name, "My Team"); + assert_eq!( + snapshot.team.description.as_deref(), + Some("My Team description") + ); + assert_eq!(snapshot.members.len(), 2); + assert_eq!(snapshot.format, FORMAT_DISCRIMINATOR); + assert_eq!(snapshot.version, FORMAT_VERSION); + } + + #[test] + fn decode_rejects_agent_snapshot_json_as_team_snapshot() { + // An agent snapshot JSON (format: "buzz-agent-snapshot") must NOT + // parse as a team snapshot — serde rejects missing `team`/`members` + // fields, or the discriminator check catches it either way. + use crate::managed_agents::agent_snapshot::{build_snapshot, encode_snapshot_json}; + let agent = build_snapshot(&agent_record("Solo"), MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&agent).unwrap(); + let result = decode_team_snapshot_json(&bytes); + assert!( + result.is_err(), + "agent snapshot JSON must not parse as team snapshot" + ); + } + + #[test] + fn decode_rejects_team_png_as_agent_snapshot() { + // A .team.png must not silently succeed when passed to the agent PNG + // decoder — the keyword differs so the agent decoder returns an error. + use crate::managed_agents::agent_snapshot::decode_snapshot_png; + let snapshot = two_member_team(); + let png_bytes = encode_team_snapshot_png(&snapshot).unwrap(); + let result = decode_snapshot_png(&png_bytes); + assert!(result.is_err(), ".team.png must not parse as .agent.png"); + } + + #[test] + fn decode_team_png_with_member_memory_succeeds() { + // Build a memory-bearing .team.png by bypassing the encoder + // (pre-guard-lift this was rejected; now it round-trips cleanly). + let alice = build_snapshot(&agent_record("Alice"), MemoryLevel::None, vec![], None); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "private".to_string(), + }]; + let bob = build_snapshot(&agent_record("Bob"), MemoryLevel::Everything, entries, None); + let snapshot = build_team_snapshot(&team_record("Team"), vec![alice, bob]); + + // Now that the guard is lifted, encode directly. + let png = encode_team_snapshot_png(&snapshot).unwrap(); + + let decoded = decode_team_snapshot_png(&png).unwrap(); + assert_eq!(decoded.members.len(), 2); + assert_eq!(decoded.members[1].memory.level, MemoryLevel::Everything); + assert_eq!(decoded.members[1].memory.entries.len(), 1); + } +} diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 33de8c7f45..6ee8c0b2ed 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -3,27 +3,13 @@ use std::{fs, path::PathBuf}; use tauri::AppHandle; use crate::{ - managed_agents::{managed_agents_base_dir, PersonaRecord, TeamRecord}, + managed_agents::{managed_agents_base_dir, ManagedAgentRecord, TeamRecord}, util::now_iso, }; use super::team_repair::team_persona_key; -#[derive(Debug, Clone, serde::Serialize)] -pub struct TeamPersonaPreview { - pub display_name: String, - pub system_prompt: String, - pub avatar_url: Option, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct ParsedTeamPreview { - pub name: String, - pub description: Option, - pub personas: Vec, -} - -fn teams_store_path(app: &AppHandle) -> Result { +pub(crate) fn teams_store_path(app: &AppHandle) -> Result { Ok(managed_agents_base_dir(app)?.join("teams.json")) } @@ -45,20 +31,27 @@ struct BuiltInTeam { persona_ids: &'static [&'static str], } -const BUILT_IN_TEAMS: &[BuiltInTeam] = &[BuiltInTeam { +const BUILT_IN_TEAMS: &[BuiltInTeam] = &[]; + +// Built-in teams that have been retired. A stored copy that still exactly +// matches its seed is purged on load (the user never touched it); customized +// copies are demoted to user-owned teams by the retirement loop in +// merge_teams_impl. +const RETIRED_BUILT_IN_TEAMS: &[BuiltInTeam] = &[BuiltInTeam { id: "builtin-team:fizz", name: "Fizz", description: Some("Fizz works carefully and collaboratively."), persona_ids: &["builtin:fizz"], }]; -fn built_in_team_records(now: &str) -> Vec { - BUILT_IN_TEAMS +fn built_in_team_records(built_ins: &[BuiltInTeam], now: &str) -> Vec { + built_ins .iter() .map(|team| TeamRecord { id: team.id.to_string(), name: team.name.to_string(), description: team.description.map(|s| s.to_string()), + instructions: None, persona_ids: team.persona_ids.iter().map(|s| s.to_string()).collect(), is_builtin: true, source_dir: None, @@ -71,18 +64,28 @@ fn built_in_team_records(now: &str) -> Vec { .collect() } -fn built_in_team_order(id: &str) -> Option { - BUILT_IN_TEAMS.iter().position(|team| team.id == id) +fn built_in_team_order(built_ins: &[BuiltInTeam], id: &str) -> Option { + built_ins.iter().position(|team| team.id == id) } -/// Add missing built-in teams, demote stale built-ins, and preserve any -/// user customizations to existing built-in teams (name, description, -/// persona membership). Returns the merged list and whether the store -/// changed. -fn merge_teams(mut stored: Vec, now: &str) -> (Vec, bool) { +/// Add missing built-in teams, purge pristine retired teams, demote stale +/// built-ins, and preserve any user customizations to existing built-in teams +/// (name, description, persona membership). Returns the merged list and whether +/// the store changed. +fn merge_teams(stored: Vec, now: &str) -> (Vec, bool) { + merge_teams_impl(BUILT_IN_TEAMS, RETIRED_BUILT_IN_TEAMS, stored, now) +} + +fn merge_teams_impl( + built_ins: &[BuiltInTeam], + retired: &[BuiltInTeam], + mut stored: Vec, + now: &str, +) -> (Vec, bool) { let mut changed = false; - for built_in in built_in_team_records(now) { + // Seed missing built-ins / re-promote existing ones that were downgraded. + for built_in in built_in_team_records(built_ins, now) { if let Some(existing) = stored.iter_mut().find(|record| record.id == built_in.id) { if !existing.is_builtin { existing.is_builtin = true; @@ -95,12 +98,34 @@ fn merge_teams(mut stored: Vec, now: &str) -> (Vec, bool } } + // Purge stored copies that are still pristine w.r.t. a retired seed. The + // user never touched them, so there is nothing to preserve. + let before = stored.len(); + stored.retain(|record| { + !retired.iter().any(|seed| { + record.is_builtin + && record.id == seed.id + && record.name == seed.name + && record.description.as_deref() == seed.description + && record + .persona_ids + .iter() + .map(String::as_str) + .eq(seed.persona_ids.iter().copied()) + && record.source_dir.is_none() + && !record.is_symlink + }) + }); + if stored.len() != before { + changed = true; + } + // Demote any stored team flagged as built-in whose id is no longer in - // BUILT_IN_TEAMS (e.g. a built-in that has been retired). The record - // stays so existing references keep working; it becomes a user-owned - // custom team they can edit or delete. + // built_ins (e.g. a built-in that has been retired). The record stays so + // existing references keep working; it becomes a user-owned custom team + // they can edit or delete. for record in stored.iter_mut() { - if record.is_builtin && built_in_team_order(&record.id).is_none() { + if record.is_builtin && built_in_team_order(built_ins, &record.id).is_none() { record.is_builtin = false; record.updated_at = now.to_string(); changed = true; @@ -121,6 +146,28 @@ pub fn validate_team_deletion(team: &TeamRecord) -> Result<(), String> { Ok(()) } +/// Read and merge built-in teams without persisting changes. +/// +/// Returns the merged, sorted team list. No file is written — callers that +/// only need the current logical state (e.g. the snapshot-import pre-read) +/// use this to avoid a write-on-load side effect. +pub(crate) fn load_teams_readonly(path: &std::path::Path) -> Result, String> { + let now = now_iso(); + + let records = if path.exists() { + let content = fs::read_to_string(path) + .map_err(|error| format!("failed to read teams store: {error}"))?; + serde_json::from_str::>(&content) + .map_err(|error| format!("failed to parse teams store: {error}"))? + } else { + Vec::new() + }; + + let (mut records, _changed) = merge_teams(records, &now); + sort_teams(&mut records); + Ok(records) +} + pub fn load_teams(app: &AppHandle) -> Result, String> { let path = teams_store_path(app)?; let now = now_iso(); @@ -154,191 +201,27 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String> crate::managed_agents::storage::atomic_write_json(&path, &payload) } -/// Teams directory: `/agents/teams/` -pub(super) fn teams_dir(app: &AppHandle) -> Result { - let dir = managed_agents_base_dir(app)?.join("teams"); - fs::create_dir_all(&dir).map_err(|e| format!("failed to create teams dir: {e}"))?; - Ok(dir) -} - -/// Validate team/pack ID: only `[a-zA-Z0-9._-]+` allowed (zip-slip defense). -pub(crate) fn validate_team_id(id: &str) -> Result<(), String> { - if id.is_empty() { - return Err("team ID is empty".into()); - } - if id.len() > 128 { - return Err(format!("team ID too long: {} chars (max 128)", id.len())); - } - if !id - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') - { - return Err(format!( - "team ID contains invalid characters: \"{id}\". Only [a-zA-Z0-9._-] allowed." - )); - } - if id.starts_with('.') { - return Err(format!("team ID \"{id}\" must not start with '.'")); - } - if !id.chars().any(|c| c.is_ascii_alphanumeric()) { - return Err(format!( - "team ID \"{id}\" must contain at least one alphanumeric character" - )); - } - Ok(()) -} - -/// Copy a directory tree, skipping symlinks (zip-slip defense). -fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> { - fs::create_dir_all(dst).map_err(|e| format!("failed to create {}: {e}", dst.display()))?; - for entry in fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))? { - let entry = entry.map_err(|e| format!("dir entry error: {e}"))?; - let ft = entry - .file_type() - .map_err(|e| format!("file type error: {e}"))?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - - if ft.is_symlink() { - continue; - } else if ft.is_dir() { - copy_dir_no_symlinks(&src_path, &dst_path)?; - } else if ft.is_file() { - fs::copy(&src_path, &dst_path) - .map_err(|e| format!("failed to copy {}: {e}", src_path.display()))?; - } - } - Ok(()) -} - -/// Import a team from a local directory in open plugin format. -/// -/// Copies the directory into `/agents/teams//`, -/// creates PersonaRecords for each persona, creates a TeamRecord with source_dir set. -pub fn import_team_from_directory( - app: &AppHandle, - source_dir: &std::path::Path, - symlink: bool, -) -> Result { - use uuid::Uuid; - - // 1. Validate + resolve at source - let resolved = buzz_persona_pkg::resolve::resolve_pack(source_dir) - .map_err(|e| format!("team directory validation failed: {e}"))?; - - // 2. Sanitize team ID - validate_team_id(&resolved.id)?; - - // 3. Check for existing team with same ID - let teams_base = teams_dir(app)?; - let dest = teams_base.join(&resolved.id); - if dest.exists() { - return Err(format!( - "Team \"{}\" is already installed. Delete it first or use sync.", - resolved.id - )); - } - - // 4. Determine install mode: symlink or copy - let source_is_symlink = fs::symlink_metadata(source_dir) - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false); - let use_symlink = symlink || source_is_symlink; - - if use_symlink { - // Resolve the canonical target for symlink - let canonical = fs::canonicalize(source_dir) - .map_err(|e| format!("failed to resolve symlink target: {e}"))?; - #[cfg(unix)] - { - std::os::unix::fs::symlink(&canonical, &dest) - .map_err(|e| format!("failed to create symlink: {e}"))?; - } - #[cfg(not(unix))] - { - // Fallback to copy on non-unix - copy_dir_no_symlinks(source_dir, &dest)?; - } - } else { - copy_dir_no_symlinks(source_dir, &dest)?; - } - - // 5. Re-validate the copy/symlink target (defense-in-depth) - let re_resolved = buzz_persona_pkg::resolve::resolve_pack(&dest).map_err(|e| { - // Clean up on failure - if use_symlink { - let _ = fs::remove_file(&dest); - } else { - let _ = fs::remove_dir_all(&dest); - } - format!("team re-validation failed after install: {e}") - })?; - - // 6. Create PersonaRecords - let now = now_iso(); - let new_personas: Vec = re_resolved - .personas +/// Names of managed agents that still reference `team` — either via the +/// legacy `persona_team_dir` link (directory-backed teams only) or the +/// `team_id` field (every team kind, all agents created after the team_id +/// seam landed). Used to block team deletion while agents still depend on it. +fn agents_referencing_team<'a>( + agents: &'a [ManagedAgentRecord], + team: &TeamRecord, +) -> Vec<&'a str> { + let persona_key = team_persona_key(team); + agents .iter() - .map(|p| PersonaRecord { - id: Uuid::new_v4().to_string(), - display_name: p.display_name.clone(), - avatar_url: p.avatar.clone(), - system_prompt: p.system_prompt.clone(), - runtime: p.runtime.clone(), - model: p.model.clone(), - provider: p.llm_provider.clone(), - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - source_team: Some(resolved.id.clone()), - source_team_persona_slug: Some(p.name.clone()), - env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars( - p.runtime_env_vars.iter().cloned(), - ), - created_at: now.clone(), - updated_at: now.clone(), + .filter(|a| { + a.persona_team_dir + .as_ref() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + == Some(persona_key) + || a.team_id.as_deref() == Some(team.id.as_str()) }) - .collect(); - - let persona_ids: Vec = new_personas.iter().map(|p| p.id.clone()).collect(); - - // 7. Save personas - let mut personas = super::load_personas(app)?; - personas.extend(new_personas); - super::save_personas(app, &personas)?; - - // 8. Create and save TeamRecord - let symlink_target = if use_symlink { - fs::canonicalize(source_dir) - .ok() - .map(|p| p.display().to_string()) - } else { - None - }; - - let team = TeamRecord { - id: resolved.id, - name: resolved.name, - description: if resolved.description.is_empty() { - None - } else { - Some(resolved.description) - }, - persona_ids, - is_builtin: false, - source_dir: Some(dest), - is_symlink: use_symlink, - symlink_target, - version: Some(resolved.version), - created_at: now.clone(), - updated_at: now, - }; - - let mut teams = load_teams(app)?; - teams.push(team.clone()); - save_teams(app, &teams)?; - - Ok(team) + .map(|a| a.name.as_str()) + .collect() } /// Delete a team, cascading removal of its sourced personas and backing dir. @@ -357,37 +240,26 @@ pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result = agents - .iter() - .filter(|a| { - a.persona_team_dir - .as_ref() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - == Some(persona_key.as_str()) - }) - .map(|a| a.name.as_str()) - .collect(); - if !referencing.is_empty() { - return Err(format!( - "Cannot delete team \"{team_id}\": {} agent(s) still reference it ({}). \ - Delete or reconfigure them first.", - referencing.len(), - referencing.join(", ") - )); - } - - // 2. Remove all PersonaRecords sourced from this team + // 1. Remove all PersonaRecords sourced from this team let mut personas = super::load_personas(app)?; // Capture the d-tag of each cascaded persona BEFORE removal so the // caller can tombstone its kind:30175 coordinate on the relay. @@ -399,7 +271,7 @@ pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result Result Result { - use uuid::Uuid; - - let teams = load_teams(app)?; - let team = teams - .iter() - .find(|t| t.id == team_id) - .ok_or_else(|| format!("team {team_id} not found"))?; - - let source_dir = team - .source_dir - .as_ref() - .ok_or_else(|| format!("team {team_id} is not directory-backed"))?; - - // Personas reference the team's directory name (pack manifest ID) in - // source_team, which may differ from team_id for pre-backfill teams. - let persona_key = team_persona_key(team).to_string(); - - if !source_dir.exists() { - return Err(format!( - "team directory does not exist: {}", - source_dir.display() - )); - } - - // Resolve current state of the directory - let resolved = buzz_persona_pkg::resolve::resolve_pack(source_dir) - .map_err(|e| format!("failed to resolve team directory: {e}"))?; - - let mut personas = super::load_personas(app)?; - let now = now_iso(); - - let mut added = Vec::new(); - let mut removed = Vec::new(); - let mut updated = Vec::new(); - - // Find existing personas for this team - let existing_slugs: Vec<(String, String)> = personas - .iter() - .filter(|p| p.source_team.as_deref() == Some(persona_key.as_str())) - .map(|p| { - ( - p.source_team_persona_slug.clone().unwrap_or_default(), - p.id.clone(), - ) - }) - .collect(); - - // Check for new personas in directory - for dir_persona in &resolved.personas { - if let Some((_slug, persona_id)) = existing_slugs - .iter() - .find(|(slug, _)| slug == &dir_persona.name) - { - // Existing persona — check for content changes - if let Some(record) = personas.iter_mut().find(|p| p.id == *persona_id) { - let mut changed = false; - if record.display_name != dir_persona.display_name { - record.display_name = dir_persona.display_name.clone(); - changed = true; - } - if record.system_prompt != dir_persona.system_prompt { - record.system_prompt = dir_persona.system_prompt.clone(); - changed = true; - } - if record.avatar_url != dir_persona.avatar { - record.avatar_url = dir_persona.avatar.clone(); - changed = true; - } - if record.runtime != dir_persona.runtime { - record.runtime = dir_persona.runtime.clone(); - changed = true; - } - if record.model != dir_persona.model { - record.model = dir_persona.model.clone(); - changed = true; - } - if record.provider != dir_persona.llm_provider { - record.provider = dir_persona.llm_provider.clone(); - changed = true; - } - if changed { - record.updated_at = now.clone(); - updated.push(persona_id.clone()); - } - } - } else { - // New persona — create record - let new_persona = PersonaRecord { - id: Uuid::new_v4().to_string(), - display_name: dir_persona.display_name.clone(), - avatar_url: dir_persona.avatar.clone(), - system_prompt: dir_persona.system_prompt.clone(), - runtime: dir_persona.runtime.clone(), - model: dir_persona.model.clone(), - provider: dir_persona.llm_provider.clone(), - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - source_team: Some(persona_key.clone()), - source_team_persona_slug: Some(dir_persona.name.clone()), - env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars( - dir_persona.runtime_env_vars.iter().cloned(), - ), - created_at: now.clone(), - updated_at: now.clone(), - }; - added.push(new_persona.id.clone()); - personas.push(new_persona); - } - } - - // Check for personas removed from directory - let dir_slugs: Vec<&str> = resolved.personas.iter().map(|p| p.name.as_str()).collect(); - let to_remove: Vec = existing_slugs - .iter() - .filter(|(slug, _)| !dir_slugs.contains(&slug.as_str())) - .map(|(_, id)| id.clone()) - .collect(); - - // Only remove if no active managed agent uses the persona - let agents = crate::managed_agents::load_managed_agents(app)?; - for persona_id in &to_remove { - let in_use = agents - .iter() - .any(|a| a.persona_id.as_deref() == Some(persona_id)); - if !in_use { - personas.retain(|p| p.id != *persona_id); - removed.push(persona_id.clone()); - } - } - - // Update team metadata if changed - let mut teams = load_teams(app)?; - let mut metadata_changed = false; - if let Some(team_record) = teams.iter_mut().find(|t| t.id == team_id) { - if team_record.name != resolved.name { - team_record.name = resolved.name; - metadata_changed = true; - } - let new_desc = if resolved.description.is_empty() { - None - } else { - Some(resolved.description) - }; - if team_record.description != new_desc { - team_record.description = new_desc; - metadata_changed = true; - } - let new_version = Some(resolved.version); - if team_record.version != new_version { - team_record.version = new_version; - metadata_changed = true; - } - // Update persona_ids to reflect current state - let current_ids: Vec = personas - .iter() - .filter(|p| p.source_team.as_deref() == Some(persona_key.as_str())) - .map(|p| p.id.clone()) - .collect(); - if team_record.persona_ids != current_ids { - team_record.persona_ids = current_ids; - metadata_changed = true; - } - if metadata_changed { - team_record.updated_at = now; - } - } - - super::save_personas(app, &personas)?; - save_teams(app, &teams)?; - - Ok(crate::managed_agents::SyncResult { - personas_added: added, - personas_removed: removed, - personas_updated: updated, - metadata_changed, - }) -} - -/// Encode a team as a JSON blob for export. The format includes the team's -/// name, description, and the full persona data for each member (so the -/// import side can recreate personas that don't exist locally). -pub fn encode_team_json(team: &TeamRecord, personas: &[PersonaRecord]) -> Result, String> { - let mut missing_persona_ids = Vec::new(); - let mut resolved_personas = Vec::with_capacity(team.persona_ids.len()); - - for persona_id in &team.persona_ids { - let Some(persona) = personas - .iter() - .find(|candidate| candidate.id == *persona_id) - else { - missing_persona_ids.push(persona_id.clone()); - continue; - }; - - resolved_personas.push(serde_json::json!({ - "displayName": persona.display_name, - "systemPrompt": persona.system_prompt, - "avatarUrl": persona.avatar_url, - })); - } - - if !missing_persona_ids.is_empty() { - return Err(format!( - "Team {} references missing personas: {}. Repair the team before exporting.", - team.name, - missing_persona_ids.join(", ") - )); - } - - let map = serde_json::json!({ - "version": 1, - "type": "team", - "name": team.name, - "description": team.description, - "personas": resolved_personas, - }); - - serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize team JSON: {e}")) -} - -/// Parse a team JSON file. Returns the team name, description, and embedded persona previews. -pub fn parse_team_json(json_bytes: &[u8]) -> Result { - let v: serde_json::Value = - serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?; - - let version = v.get("version").and_then(|v| v.as_u64()).unwrap_or(0); - if version != 1 { - return Err(format!("Unsupported team version: {version}")); - } - - let file_type = v.get("type").and_then(|v| v.as_str()).unwrap_or(""); - if file_type != "team" { - return Err("Not a team export file".to_string()); - } - - let name = v - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if name.is_empty() { - return Err("Team name is empty".to_string()); - } - - let description = v - .get("description") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - - let personas = v - .get("personas") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|p| { - let display_name = p - .get("displayName") - .and_then(|v| v.as_str())? - .trim() - .to_string(); - let system_prompt = p - .get("systemPrompt") - .and_then(|v| v.as_str())? - .trim() - .to_string(); - let avatar_url = p - .get("avatarUrl") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - if display_name.is_empty() || system_prompt.is_empty() { - return None; - } - Some(TeamPersonaPreview { - display_name, - system_prompt, - avatar_url, - }) - }) - .collect() - }) - .unwrap_or_default(); - - Ok(ParsedTeamPreview { - name, - description, - personas, - }) -} - #[cfg(test)] -mod tests { - use super::{ - encode_team_json, merge_teams, parse_team_json, sort_teams, validate_team_deletion, - BUILT_IN_TEAMS, - }; - use crate::managed_agents::{PersonaRecord, TeamRecord}; - - fn team(id: &str, name: &str) -> TeamRecord { - TeamRecord { - id: id.to_string(), - name: name.to_string(), - description: None, - persona_ids: Vec::new(), - is_builtin: false, - source_dir: None, - is_symlink: false, - symlink_target: None, - version: None, - created_at: "2026-03-20T00:00:00Z".to_string(), - updated_at: "2026-03-20T00:00:00Z".to_string(), - } - } - - #[test] - fn sort_teams_alphabetical_case_insensitive() { - let mut teams = vec![team("3", "Zulu"), team("1", "alpha"), team("2", "Bravo")]; - sort_teams(&mut teams); - - let names: Vec<&str> = teams.iter().map(|t| t.name.as_str()).collect(); - assert_eq!(names, vec!["alpha", "Bravo", "Zulu"]); - } - - #[test] - fn sort_teams_breaks_ties_by_id() { - let mut teams = vec![team("b", "same"), team("a", "same")]; - sort_teams(&mut teams); - - let ids: Vec<&str> = teams.iter().map(|t| t.id.as_str()).collect(); - assert_eq!(ids, vec!["a", "b"]); - } - - #[test] - fn sort_teams_empty_is_noop() { - let mut teams: Vec = Vec::new(); - sort_teams(&mut teams); - assert!(teams.is_empty()); - } - - fn persona(id: &str, name: &str, prompt: &str) -> PersonaRecord { - PersonaRecord { - id: id.to_string(), - display_name: name.to_string(), - avatar_url: None, - system_prompt: prompt.to_string(), - runtime: None, - model: None, - provider: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - env_vars: std::collections::BTreeMap::new(), - created_at: "2026-03-20T00:00:00Z".to_string(), - updated_at: "2026-03-20T00:00:00Z".to_string(), - } - } - - #[test] - fn encode_parse_round_trip() { - let t = team("t1", "My Team"); - let t = TeamRecord { - description: Some("A great team".to_string()), - persona_ids: vec!["p1".to_string(), "p2".to_string()], - ..t - }; - let personas = vec![ - persona("p1", "Alice", "You are Alice"), - persona("p2", "Bob", "You are Bob"), - ]; - - let bytes = encode_team_json(&t, &personas).unwrap(); - let parsed = parse_team_json(&bytes).unwrap(); - - assert_eq!(parsed.name, "My Team"); - assert_eq!(parsed.description.as_deref(), Some("A great team")); - assert_eq!(parsed.personas.len(), 2); - assert_eq!(parsed.personas[0].display_name, "Alice"); - assert_eq!(parsed.personas[0].system_prompt, "You are Alice"); - assert_eq!(parsed.personas[1].display_name, "Bob"); - assert_eq!(parsed.personas[1].system_prompt, "You are Bob"); - } - - #[test] - fn encode_errors_for_missing_personas() { - let t = TeamRecord { - persona_ids: vec!["p1".to_string(), "missing".to_string()], - ..team("t1", "Team") - }; - let personas = vec![persona("p1", "Alice", "prompt")]; - - let err = encode_team_json(&t, &personas).unwrap_err(); - - assert_eq!( - err, - "Team Team references missing personas: missing. Repair the team before exporting." - ); - } - - #[test] - fn parse_team_json_invalid_version() { - let json = serde_json::json!({ - "version": 99, - "type": "team", - "name": "X", - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let err = parse_team_json(&bytes).unwrap_err(); - assert!(err.contains("Unsupported team version"), "{err}"); - } - - #[test] - fn parse_team_json_wrong_type() { - let json = serde_json::json!({ - "version": 1, - "type": "persona", - "name": "X", - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let err = parse_team_json(&bytes).unwrap_err(); - assert!(err.contains("Not a team export file"), "{err}"); - } - - #[test] - fn parse_team_json_empty_name() { - let json = serde_json::json!({ - "version": 1, - "type": "team", - "name": " ", - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let err = parse_team_json(&bytes).unwrap_err(); - assert!(err.contains("Team name is empty"), "{err}"); - } - - #[test] - fn parse_team_json_skips_invalid_personas() { - let json = serde_json::json!({ - "version": 1, - "type": "team", - "name": "Team", - "personas": [ - { "displayName": "Good", "systemPrompt": "prompt" }, - { "displayName": "", "systemPrompt": "prompt" }, - { "displayName": "NoPrompt" }, - ], - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let parsed = parse_team_json(&bytes).unwrap(); - assert_eq!(parsed.personas.len(), 1); - assert_eq!(parsed.personas[0].display_name, "Good"); - } - - #[test] - fn parse_team_json_no_personas_key() { - let json = serde_json::json!({ - "version": 1, - "type": "team", - "name": "Fizz", - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let parsed = parse_team_json(&bytes).unwrap(); - assert!(parsed.personas.is_empty()); - assert_eq!(parsed.name, "Fizz"); - } - - #[test] - fn merge_teams_adds_missing_built_ins() { - let (records, changed) = merge_teams(Vec::new(), "2026-05-07T00:00:00Z"); - - assert!(changed); - assert_eq!(records.len(), BUILT_IN_TEAMS.len()); - assert!(records.iter().all(|record| record.is_builtin)); - let names: Vec<&str> = records.iter().map(|t| t.name.as_str()).collect(); - assert_eq!(names, vec!["Fizz"]); - } - - #[test] - fn merge_teams_preserves_user_customizations_to_builtin() { - let mut customized = team("builtin-team:fizz", "Fizz (mine)"); - customized.is_builtin = true; - customized.persona_ids = vec!["builtin:fizz".to_string()]; - - let (records, _changed) = merge_teams(vec![customized], "2026-05-07T00:00:00Z"); - - let fizz = records - .iter() - .find(|t| t.id == "builtin-team:fizz") - .expect("Fizz built-in should exist"); - assert_eq!(fizz.name, "Fizz (mine)"); - assert_eq!(fizz.persona_ids, vec!["builtin:fizz".to_string()]); - assert!(fizz.is_builtin); - } - - #[test] - fn merge_teams_preserves_unrelated_user_teams() { - let user_team = team("user-uuid", "My Team"); - let (records, _changed) = merge_teams(vec![user_team], "2026-05-07T00:00:00Z"); - - assert!(records.iter().any(|t| t.id == "user-uuid")); - assert!(records.iter().any(|t| t.id == "builtin-team:fizz")); - } - - #[test] - fn merge_teams_demotes_retired_built_ins() { - let mut retired = team("builtin-team:legacy", "Legacy"); - retired.is_builtin = true; - - let (records, changed) = merge_teams(vec![retired], "2026-05-07T00:00:00Z"); - - assert!(changed); - let demoted = records - .iter() - .find(|t| t.id == "builtin-team:legacy") - .expect("retired built-in should be retained as a custom team"); - assert!(!demoted.is_builtin); - assert_eq!(demoted.updated_at, "2026-05-07T00:00:00Z"); - } - - #[test] - fn merge_teams_repromotes_existing_builtin_marked_as_custom() { - // If someone hand-edits the store and flips is_builtin to false on a - // canonical built-in id, merge_teams should restore the flag. - let mut downgraded = team("builtin-team:fizz", "Fizz"); - downgraded.is_builtin = false; - - let (records, changed) = merge_teams(vec![downgraded], "2026-05-07T00:00:00Z"); - - assert!(changed); - let fizz = records - .iter() - .find(|t| t.id == "builtin-team:fizz") - .expect("Fizz should exist"); - assert!(fizz.is_builtin); - } - - #[test] - fn validate_team_deletion_rejects_built_ins() { - let mut built_in = team("builtin-team:fizz", "Fizz"); - built_in.is_builtin = true; - - let err = validate_team_deletion(&built_in).unwrap_err(); - assert_eq!(err, "Built-in teams cannot be deleted."); - } -} +#[path = "teams_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs new file mode 100644 index 0000000000..7e9c7656b4 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -0,0 +1,384 @@ +//! Unit tests for `managed_agents/teams.rs`. +//! +//! Kept in a sibling file so `teams.rs` stays under the 1000-line gate; +//! `#[path]`-included from there. + +use super::{ + agents_referencing_team, load_teams_readonly, merge_teams, merge_teams_impl, sort_teams, + validate_team_deletion, BuiltInTeam, +}; +use crate::managed_agents::{ManagedAgentRecord, TeamRecord}; + +fn team(id: &str, name: &str) -> TeamRecord { + TeamRecord { + id: id.to_string(), + name: name.to_string(), + description: None, + instructions: None, + persona_ids: Vec::new(), + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-03-20T00:00:00Z".to_string(), + updated_at: "2026-03-20T00:00:00Z".to_string(), + } +} + +#[test] +fn sort_teams_alphabetical_case_insensitive() { + let mut teams = vec![team("3", "Zulu"), team("1", "alpha"), team("2", "Bravo")]; + sort_teams(&mut teams); + + let names: Vec<&str> = teams.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "Bravo", "Zulu"]); +} + +#[test] +fn sort_teams_breaks_ties_by_id() { + let mut teams = vec![team("b", "same"), team("a", "same")]; + sort_teams(&mut teams); + + let ids: Vec<&str> = teams.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b"]); +} + +#[test] +fn sort_teams_empty_is_noop() { + let mut teams: Vec = Vec::new(); + sort_teams(&mut teams); + assert!(teams.is_empty()); +} + +#[test] +fn merge_teams_adds_missing_built_ins() { + let synthetic = BuiltInTeam { + id: "builtin-team:test", + name: "Test Team", + description: Some("A synthetic test team."), + persona_ids: &["builtin:test-persona"], + }; + + let (records, changed) = + merge_teams_impl(&[synthetic], &[], Vec::new(), "2026-05-07T00:00:00Z"); + + assert!(changed); + assert_eq!(records.len(), 1); + assert!(records.iter().all(|r| r.is_builtin)); + assert_eq!(records[0].id, "builtin-team:test"); +} + +#[test] +fn merge_teams_preserves_user_customizations_to_builtin() { + let synthetic = BuiltInTeam { + id: "builtin-team:test", + name: "Test Team", + description: None, + persona_ids: &["builtin:test-persona"], + }; + let mut customized = team("builtin-team:test", "Test Team (mine)"); + customized.is_builtin = true; + customized.persona_ids = vec!["builtin:test-persona".to_string()]; + + let (records, _changed) = + merge_teams_impl(&[synthetic], &[], vec![customized], "2026-05-07T00:00:00Z"); + + let found = records + .iter() + .find(|t| t.id == "builtin-team:test") + .expect("synthetic built-in should exist"); + assert_eq!(found.name, "Test Team (mine)"); + assert_eq!(found.persona_ids, vec!["builtin:test-persona".to_string()]); + assert!(found.is_builtin); +} + +#[test] +fn merge_teams_preserves_unrelated_user_teams() { + let synthetic = BuiltInTeam { + id: "builtin-team:test", + name: "Test Team", + description: None, + persona_ids: &[], + }; + let user_team = team("user-uuid", "My Team"); + + let (records, _changed) = + merge_teams_impl(&[synthetic], &[], vec![user_team], "2026-05-07T00:00:00Z"); + + assert!(records.iter().any(|t| t.id == "user-uuid")); + assert!(records.iter().any(|t| t.id == "builtin-team:test")); +} + +#[test] +fn merge_teams_demotes_retired_built_ins() { + let mut retired = team("builtin-team:legacy", "Legacy"); + retired.is_builtin = true; + + let (records, changed) = merge_teams(vec![retired], "2026-05-07T00:00:00Z"); + + assert!(changed); + let demoted = records + .iter() + .find(|t| t.id == "builtin-team:legacy") + .expect("retired built-in should be retained as a custom team"); + assert!(!demoted.is_builtin); + assert_eq!(demoted.updated_at, "2026-05-07T00:00:00Z"); +} + +#[test] +fn merge_teams_repromotes_existing_builtin_marked_as_custom() { + // If someone hand-edits the store and flips is_builtin to false on a + // canonical built-in id, merge_teams_impl should restore the flag. + let synthetic = BuiltInTeam { + id: "builtin-team:test", + name: "Test Team", + description: None, + persona_ids: &[], + }; + let mut downgraded = team("builtin-team:test", "Test Team"); + downgraded.is_builtin = false; + + let (records, changed) = + merge_teams_impl(&[synthetic], &[], vec![downgraded], "2026-05-07T00:00:00Z"); + + assert!(changed); + let found = records + .iter() + .find(|t| t.id == "builtin-team:test") + .expect("synthetic built-in should exist"); + assert!(found.is_builtin); +} + +#[test] +fn validate_team_deletion_rejects_built_ins() { + let mut built_in = team("builtin-team:fizz", "Fizz"); + built_in.is_builtin = true; + + let err = validate_team_deletion(&built_in).unwrap_err(); + assert_eq!(err, "Built-in teams cannot be deleted."); +} + +// ── agents_referencing_team ───────────────────────────────────────────── + +fn managed_agent(name: &str) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: name.to_string(), + name: name.to_string(), + persona_id: None, + team_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "buzz-agent".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: false, + runtime_pid: None, + backend: crate::managed_agents::BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: crate::managed_agents::RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + relay_mesh: None, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + } +} + +/// A new-style agent (created after the `team_id` seam landed) that links to +/// a JSON-only team purely via `team_id` — the only kind of team that carries +/// no `source_dir`/`persona_team_dir` at all — must still be caught, or the +/// "team in use" delete guard silently never fires for it. +#[test] +fn agents_referencing_team_matches_on_team_id() { + let t = team("json-team-1", "Json Team"); + let mut linked = managed_agent("Linked Agent"); + linked.team_id = Some("json-team-1".to_string()); + let unrelated = managed_agent("Unrelated Agent"); + + let agents = vec![linked, unrelated]; + let referencing = agents_referencing_team(&agents, &t); + + assert_eq!(referencing, vec!["Linked Agent"]); +} + +/// Legacy pack-backed agents that predate the `team_id` field record their +/// link solely via `persona_team_dir` (matched against the team's directory +/// name) — this path must keep working after the `team_id` check was added. +#[test] +fn agents_referencing_team_matches_on_persona_team_dir() { + let mut t = team("uuid-1", "Dir Team"); + t.source_dir = Some(std::path::PathBuf::from("/teams/com.example.pack")); + let mut legacy = managed_agent("Legacy Agent"); + legacy.persona_team_dir = Some(std::path::PathBuf::from("/installed/com.example.pack")); + let unrelated = managed_agent("Unrelated Agent"); + + let agents = vec![legacy, unrelated]; + let referencing = agents_referencing_team(&agents, &t); + + assert_eq!(referencing, vec!["Legacy Agent"]); +} + +#[test] +fn agents_referencing_team_empty_when_no_matches() { + let t = team("json-team-2", "Json Team"); + let agents = vec![managed_agent("Agent A"), managed_agent("Agent B")]; + + assert!(agents_referencing_team(&agents, &t).is_empty()); +} + +// Migration pins — exercise the real merge_teams wrapper (with production consts). + +#[test] +fn migration_pristine_fizz_is_purged() { + // A stored record that exactly matches the retired Fizz seed is dropped + // on load — the user never touched it, so nothing is lost. + let pristine = TeamRecord { + id: "builtin-team:fizz".to_string(), + name: "Fizz".to_string(), + description: Some("Fizz works carefully and collaboratively.".to_string()), + instructions: None, + persona_ids: vec!["builtin:fizz".to_string()], + is_builtin: true, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }; + + let (records, changed) = merge_teams(vec![pristine], "2026-07-01T00:00:00Z"); + + assert!(changed); + assert!(!records.iter().any(|t| t.id == "builtin-team:fizz")); +} + +#[test] +fn migration_customized_fizz_is_demoted_to_user_team() { + // A stored Fizz that was renamed (or had a persona added) is retained + // but demoted to a user-owned team so the user can edit or delete it. + let customized = TeamRecord { + id: "builtin-team:fizz".to_string(), + name: "Fizz (customized)".to_string(), + description: Some("Fizz works carefully and collaboratively.".to_string()), + instructions: None, + persona_ids: vec!["builtin:fizz".to_string(), "extra:persona".to_string()], + is_builtin: true, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }; + + let (records, changed) = merge_teams(vec![customized], "2026-07-01T00:00:00Z"); + + assert!(changed); + let demoted = records + .iter() + .find(|t| t.id == "builtin-team:fizz") + .expect("customized fizz should be retained as a user-owned team"); + assert!(!demoted.is_builtin); + assert_eq!(demoted.updated_at, "2026-07-01T00:00:00Z"); +} + +#[test] +fn migration_empty_store_stays_empty() { + // Fresh installs see no teams and no spurious changed flag. + let (records, changed) = merge_teams(Vec::new(), "2026-07-01T00:00:00Z"); + + assert!(!changed); + assert!(records.is_empty()); +} + +// ── load_teams_readonly tests ────────────────────────────────────────── + +#[test] +fn load_teams_readonly_absent_file_performs_no_write() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("teams.json"); + + // File does not exist. + assert!(!path.exists()); + + let records = load_teams_readonly(&path).unwrap(); + + // Returns the merged built-in list (currently empty since BUILT_IN_TEAMS + // is empty, but the function ran merge_teams + sort_teams). + assert!(records.is_empty()); + + // The file must still NOT exist — no write-on-load side effect. + assert!( + !path.exists(), + "load_teams_readonly must not create the file" + ); +} + +#[test] +fn load_teams_readonly_surfaces_parse_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("teams.json"); + std::fs::write(&path, b"not valid json").unwrap(); + + let result = load_teams_readonly(&path); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("failed to parse teams store"), + "parse error must be surfaced" + ); +} + +#[cfg(unix)] +#[test] +fn load_teams_readonly_surfaces_read_error() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("teams.json"); + std::fs::write(&path, b"[]").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); + + let result = load_teams_readonly(&path); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("failed to read teams store"), + "read error must be surfaced" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e17a923b2c..9cb2354765 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -13,7 +13,7 @@ pub enum BackendKind { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PersonaRecord { +pub struct AgentDefinition { pub id: String, pub display_name: String, pub avatar_url: Option, @@ -63,10 +63,116 @@ pub struct PersonaRecord { /// Stored as a BTreeMap for deterministic on-disk ordering. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env_vars: BTreeMap, + /// NIP-AP behavioral defaults, stored in WIRE shape (kebab-case string, + /// not the `RespondTo` enum) so `persona_event_content` is a verbatim + /// copy and quad-absent records serialize byte-identically to the + /// pre-activation era. Copied onto instances at mint time only — spawn + /// re-snapshot never touches them. Validated at the instance boundary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respond_to: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parallelism: Option, pub created_at: String, pub updated_at: String, } +impl AgentDefinition { + /// Project this persona onto a key-less unified [`ManagedAgentRecord`] + /// (Phase 1A store fold). Identity fields stay empty — keys are minted on + /// first start. `AgentDefinition.id` becomes `slug`, preserving the 30175 + /// event coordinate (`d_tag = slug`) across the fold. + pub fn into_agent_record(self) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: self.display_name.clone(), + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: self.avatar_url, + acp_command: DEFAULT_ACP_COMMAND.to_string(), + agent_command: String::new(), + agent_command_override: None, + agent_args: Vec::new(), + mcp_command: String::new(), + turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: default_agent_parallelism(), + system_prompt: (!self.system_prompt.is_empty()).then_some(self.system_prompt), + model: self.model, + provider: self.provider, + persona_source_version: None, + env_vars: self.env_vars, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: self.created_at, + updated_at: self.updated_at, + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: Vec::new(), + display_name: Some(self.display_name), + slug: Some(self.id), + runtime: self.runtime, + name_pool: self.name_pool, + is_builtin: self.is_builtin, + is_active: self.is_active, + source_team: self.source_team, + source_team_persona_slug: self.source_team_persona_slug, + definition_respond_to: self.respond_to, + definition_respond_to_allowlist: self.respond_to_allowlist, + definition_parallelism: self.parallelism, + relay_mesh: None, + } + } +} + +impl ManagedAgentRecord { + /// Present a key-less definition record back in the legacy + /// [`AgentDefinition`] shape — the compatibility view the persona command + /// surface serves until Phase 1B unifies the UI. Inverse of + /// [`AgentDefinition::into_agent_record`] for the fields personas carry. + pub fn to_definition_view(&self) -> Option { + let slug = self.slug.clone()?; + Some(AgentDefinition { + id: slug, + display_name: self + .display_name + .clone() + .unwrap_or_else(|| self.name.clone()), + avatar_url: self.avatar_url.clone(), + system_prompt: self.system_prompt.clone().unwrap_or_default(), + runtime: self.runtime.clone(), + model: self.model.clone(), + provider: self.provider.clone(), + name_pool: self.name_pool.clone(), + is_builtin: self.is_builtin, + is_active: self.is_active, + source_team: self.source_team.clone(), + source_team_persona_slug: self.source_team_persona_slug.clone(), + env_vars: self.env_vars.clone(), + respond_to: self.definition_respond_to.clone(), + respond_to_allowlist: self.definition_respond_to_allowlist.clone(), + parallelism: self.definition_parallelism, + created_at: self.created_at.clone(), + updated_at: self.updated_at.clone(), + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayAgentInfo { pub pubkey: String, @@ -88,6 +194,9 @@ pub struct ManagedAgentRecord { pub name: String, #[serde(default)] pub persona_id: Option, + /// Team this instance was deployed from. Resolves runtime team instructions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_id: Option, /// nsec private key. Held in memory but persisted to the OS keyring (keyed /// by `pubkey`) rather than serialized to `managed-agents.json`. The /// storage layer blanks this before writing JSON once the key is safely in @@ -126,9 +235,19 @@ pub struct ManagedAgentRecord { #[serde(default)] pub agent_command_override: Option, pub agent_args: Vec, + /// Create-time snapshot of the catalog MCP command. Never read at spawn — + /// the effective MCP command is always re-derived from the runtime catalog + /// (`known_acp_runtime`) — and no longer written by updates. Kept for + /// serde compatibility with existing stores. pub mcp_command: String, + /// Deprecated: `BUZZ_ACP_TURN_TIMEOUT` is ignored by the harness and the + /// desktop no longer emits or edits it. Kept for serde compatibility with + /// existing stores; use `idle_timeout_seconds` or + /// `max_turn_duration_seconds` for turn-length control. pub turn_timeout_seconds: u64, - /// Idle timeout in seconds. If set, overrides turn_timeout_seconds. + /// Idle timeout in seconds (`BUZZ_ACP_IDLE_TIMEOUT`): how long the agent + /// may stay silent on its ACP channel mid-turn before the harness times + /// the turn out. #[serde(default)] pub idle_timeout_seconds: Option, /// Absolute wall-clock cap per turn. @@ -156,10 +275,6 @@ pub struct ManagedAgentRecord { /// for non-persona agents and for pre-existing records pending backfill. #[serde(default)] pub persona_source_version: Option, - /// Comma-separated toolset string forwarded as BUZZ_TOOLSETS to the MCP subprocess. - /// When None, the MCP server uses its own default ("default" toolset). - #[serde(default)] - pub mcp_toolsets: Option, /// Environment variables injected at spawn time. Layered as: desktop /// parent env < persona `env_vars` < this agent's `env_vars` (last wins). /// @@ -168,6 +283,11 @@ pub struct ManagedAgentRecord { pub env_vars: BTreeMap, #[serde(default = "default_start_on_app_launch")] pub start_on_app_launch: bool, + /// Auto-restart this agent when its effective spawn config drifts from + /// the running process (Chunk F). Default ON; the policy loop in the + /// frontend only fires when the agent is idle, connected, and local. + #[serde(default = "default_auto_restart_on_config_change")] + pub auto_restart_on_config_change: bool, #[serde(default)] pub runtime_pid: Option, #[serde(default)] @@ -196,6 +316,8 @@ pub struct ManagedAgentRecord { pub last_stopped_at: Option, pub last_exit_code: Option, pub last_error: Option, + #[serde(default)] + pub last_error_code: Option, /// Inbound author gate mode. Translates to `BUZZ_ACP_RESPOND_TO`. #[serde(default)] pub respond_to: RespondTo, @@ -204,6 +326,66 @@ pub struct ManagedAgentRecord { /// Preserved across mode toggles so users don't lose state. #[serde(default)] pub respond_to_allowlist: Vec, + /// Optional display name distinct from the unique `name` handle. Absorbed + /// from `AgentDefinition.display_name` (unified agent model, Phase 1A). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Stable definition slug — the former `AgentDefinition.id`. Key-less + /// records (definitions not yet instantiated) publish kind:30175 at + /// `d_tag = slug`, preserving the pre-merge event coordinates. `None` for + /// agents created directly (never persona-backed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option, + /// Absorbed from `AgentDefinition.runtime` — the preferred ACP runtime ID + /// (e.g. 'goose', 'claude'). Record-first command resolution reads this + /// before falling back to legacy persona lookup; populated by the store + /// migration and at create time, and re-mirrored from the linked + /// definition at every snapshot apply (`apply_persona_snapshot`). + /// + /// `None` means "inherit from the linked definition" (the Inherit sentinel + /// clears it). Serialization then omits the key, so boot-time + /// `materialize_agent_runtimes` re-inserts a mirror of the definition's + /// current runtime on the next launch — behaviorally identical, because + /// every apply site re-mirrors the live definition anyway. A literal + /// `"runtime": null` in the store (key present, e.g. hand-edited) is + /// honored: materialization skips it and it deserializes to `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + /// Pool of short thematic names for clones of this agent. Absorbed from + /// `AgentDefinition.name_pool`; feeds clone naming. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub name_pool: Vec, + /// Absorbed from `AgentDefinition.is_builtin`. + #[serde(default)] + pub is_builtin: bool, + /// Absorbed from `AgentDefinition.is_active` — `false` means an archived + /// definition hidden from pickers. Defaults `true` for existing records. + #[serde(default = "default_record_active")] + pub is_active: bool, + /// Absorbed from `AgentDefinition.source_team` — team ID when this + /// definition was imported from a team directory (team definitions are + /// non-editable). Distinct from `persona_team_dir`/`persona_name_in_team`, + /// which are the instance-side spawn plumbing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_team: Option, + /// Absorbed from `AgentDefinition.source_team_persona_slug` — the + /// definition's slug within its source team. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_team_persona_slug: Option, + /// NIP-AP definition-level behavioral defaults, absorbed from + /// `AgentDefinition` in WIRE shape (kebab-case string / optional u32), + /// distinct from the instance-side `respond_to`/`respond_to_allowlist`/ + /// `parallelism` fields above: these are what a *definition* advertises + /// and are copied onto instances at mint time only. Wire shape (not the + /// `RespondTo` enum) so absent-ness and unknown future mode strings + /// round-trip byte-identically through the store — parsed/validated + /// solely at the mint boundary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition_respond_to: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub definition_respond_to_allowlist: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition_parallelism: Option, /// Typed marker for relay-mesh agents. `Some(_)` means this agent runs its /// inference through Buzz's relay-mesh local endpoint; the `model_ref` is /// the served model id to route to. `None` is a normal agent. @@ -240,6 +422,25 @@ pub struct RelayMeshConfig { pub struct ManagedAgentProcess { pub child: Child, pub log_path: PathBuf, + /// Digest of the effective spawn config at launch (see + /// `spawn_hash::spawn_config_hash`). Runtime-only — never persisted. The + /// summary builder recomputes the hash from current disk state and flags + /// `needs_restart` on mismatch. Agents adopted via a persisted + /// `runtime_pid` have no `ManagedAgentProcess` entry, so their spawn + /// config is unknown and the badge stays off. + pub spawn_config_hash: u64, + /// Whether this process was spawned in setup-listener mode (i.e. + /// `BUZZ_ACP_SETUP_PAYLOAD` was set at launch because the agent was + /// `NotReady`). Runtime-only — never persisted. Used by + /// `install_acp_runtime` to target only stuck agents for auto-restart, + /// excluding healthy in-pool agents. + pub setup_mode: bool, + /// Adapter availability status stamped at spawn time for runtimes with a + /// version gate (currently codex only; `None` for all others). Runtime-only + /// — never persisted. The summary builder compares this against the current + /// cached availability and sets `needs_restart` on drift, catching out-of- + /// band adapter changes that Phase-1 auto-restart doesn't cover. + pub adapter_availability: Option, /// Win32 Job Object owning the harness + its entire process tree. Closing /// the handle (via `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) kills the whole /// tree — the Windows mirror of the Unix process-group teardown. `None` @@ -262,7 +463,11 @@ pub struct ManagedAgentSummary { /// concrete pin (`agent_command` above is the resolved/effective command). pub agent_command_override: Option, pub agent_args: Vec, + /// Catalog-derived from the effective harness (not the record's stored + /// field), so the UI always shows what a spawn would actually use. pub mcp_command: String, + /// Deprecated passthrough of the stored record value; the harness ignores + /// it. Kept for wire compatibility. pub turn_timeout_seconds: u64, pub idle_timeout_seconds: Option, pub max_turn_duration_seconds: Option, @@ -283,7 +488,13 @@ pub struct ManagedAgentSummary { /// so the UI should not prompt a respawn — the pinned snapshot is all the /// config that remains. pub persona_orphaned: bool, - pub mcp_toolsets: Option, + /// `true` when the running process was spawned with a config that no + /// longer matches what a spawn would use today — a plain restart would + /// change what runs. Complements `persona_out_of_date`: the badge means + /// "a restart would change what runs"; out-of-date means "a respawn + /// would." Always `false` for stopped agents and for processes adopted + /// via a persisted `runtime_pid` (their spawn config is unknown). + pub needs_restart: bool, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env_vars: BTreeMap, pub backend: BackendKind, @@ -296,57 +507,14 @@ pub struct ManagedAgentSummary { pub last_stopped_at: Option, pub last_exit_code: Option, pub last_error: Option, + pub last_error_code: Option, pub start_on_app_launch: bool, + pub auto_restart_on_config_change: bool, pub log_path: String, pub respond_to: RespondTo, pub respond_to_allowlist: Vec, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateManagedAgentRequest { - pub name: String, - #[serde(default)] - pub persona_id: Option, - pub relay_url: Option, - pub acp_command: Option, - pub agent_command: Option, - /// True when `agent_command` is a runtime command the user deliberately - /// picked for a linked persona. Distinguishes a real selection, including an - /// installed alias, from a missing-runtime fallback so a persona-backed - /// create only stores an `agent_command_override` for the former. - #[serde(default)] - pub harness_override: bool, - #[serde(default)] - pub agent_args: Vec, - pub mcp_command: Option, - pub turn_timeout_seconds: Option, - pub idle_timeout_seconds: Option, - pub max_turn_duration_seconds: Option, - pub parallelism: Option, - pub system_prompt: Option, - pub avatar_url: Option, - pub model: Option, - pub mcp_toolsets: Option, - /// Environment variables for this agent. Layered on top of persona env. - #[serde(default)] - pub env_vars: BTreeMap, - #[serde(default)] - pub spawn_after_create: bool, - #[serde(default = "default_start_on_app_launch")] - pub start_on_app_launch: bool, - #[serde(default)] - pub backend: BackendKind, - #[serde(default)] - pub respond_to: RespondTo, - /// Raw allowlist as received from the frontend. Validated and normalized - /// before being written to the record. - #[serde(default)] - pub respond_to_allowlist: Vec, - #[serde(default)] - pub relay_mesh: Option, -} - #[derive(Debug, Serialize)] pub struct CreateManagedAgentResponse { pub agent: ManagedAgentSummary, @@ -355,65 +523,45 @@ pub struct CreateManagedAgentResponse { pub spawn_error: Option, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreatePersonaRequest { - pub display_name: String, - pub avatar_url: Option, - pub system_prompt: String, - #[serde(default)] - pub runtime: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub provider: Option, - #[serde(default)] - pub name_pool: Vec, - /// Environment variables for agents created from this persona. - #[serde(default)] - pub env_vars: BTreeMap, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdatePersonaRequest { - pub id: String, - pub display_name: String, - pub avatar_url: Option, - pub system_prompt: String, - #[serde(default)] - pub runtime: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub provider: Option, - #[serde(default)] - pub name_pool: Vec, - /// Environment variables for agents created from this persona. - /// - /// Absent (`None`) = don't touch the stored value (caller didn't include - /// the field). `Some(map)` = replace entirely (empty map clears all). - /// Defaulting an omitted field to an empty map would silently erase - /// stored credentials when an unrelated field is edited. - #[serde(default)] - pub env_vars: Option>, -} - #[derive(Debug, Serialize)] pub struct ManagedAgentLogResponse { pub content: String, pub log_path: String, } -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AcpAvailabilityStatus { Available, AdapterMissing, + /// Adapter binary is present but is from the deprecated package (< 1.0). Reinstall required. + AdapterOutdated, CliMissing, NotInstalled, } +/// Authentication/login status for a CLI-based ACP runtime. +/// +/// Serializes as a tagged union `{ status: "...", diagnostic?: "..." }` so +/// the TypeScript side can exhaustively switch on `status`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum AuthStatus { + /// The CLI reported a successful login. + LoggedIn, + /// The CLI exited non-zero without a config-parse signal. + LoggedOut, + /// The CLI exited non-zero and its stderr contains a config-parse error. + ConfigInvalid { + /// Trimmed excerpt of the stderr message. + diagnostic: String, + }, + /// This runtime does not have a login step (e.g. goose, buzz-agent). + NotApplicable, + /// Probe was not attempted (runtime unavailable or probe timed out). + Unknown, +} + #[derive(Debug, Clone, Serialize)] pub struct AcpRuntimeCatalogEntry { pub id: String, @@ -429,6 +577,14 @@ pub struct AcpRuntimeCatalogEntry { /// true when at least one automated install step is available pub can_auto_install: bool, pub underlying_cli_path: Option, + /// true when an npm adapter step is pending but Node.js / npm is absent. + /// The UI hides the Install button and shows a Node.js install callout. + pub node_required: bool, + /// Login/authentication status for CLI-based runtimes. + pub auth_status: AuthStatus, + /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. + #[serde(skip_serializing_if = "Option::is_none")] + pub login_hint: Option, } /// Result of a single install step (CLI or adapter). @@ -440,6 +596,11 @@ pub struct InstallStepResult { pub stdout: String, pub stderr: String, pub exit_code: Option, + /// Actionable guidance shown in the UI when this step failed due to a + /// recognized condition (e.g. EACCES on a root-owned npm global prefix). + /// `None` when the step succeeded or no pattern matched. + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, } /// Aggregate result of installing a runtime (may include CLI + adapter steps). @@ -447,6 +608,12 @@ pub struct InstallStepResult { pub struct InstallRuntimeResult { pub success: bool, pub steps: Vec, + /// Number of local agents successfully stopped and restarted after a + /// successful install. Mirrors `GlobalAgentConfigSaveResult.restarted_count`. + pub restarted_count: u32, + /// Number of agents whose stop succeeded but respawn failed. + /// Mirrors `GlobalAgentConfigSaveResult.failed_restart_count`. + pub failed_restart_count: u32, } #[derive(Debug, Clone, Serialize)] @@ -469,55 +636,6 @@ pub struct ManagedAgentPrereqsInfo { pub mcp: CommandAvailabilityInfo, } -/// Patch request for updating a managed agent's mutable fields. -/// -/// Tri-state nullable semantics via `Option>`: -/// - Field absent in JSON → `None` (don't touch) -/// - `"field": null` → `Some(None)` (clear to default) -/// - `"field": "value"` → `Some(Some("value"))` (set) -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateManagedAgentRequest { - pub pubkey: String, - /// Absent = don't touch. Present = rename the agent. - #[serde(default)] - pub name: Option, - /// Absent = don't touch. null = clear to agent default. "id" = set. - #[serde(default)] - pub model: Option>, - #[serde(default)] - pub system_prompt: Option>, - #[serde(default)] - pub mcp_toolsets: Option>, - /// Absent = don't touch. Present = replace the env_vars map entirely. - #[serde(default)] - pub env_vars: Option>, - #[serde(default)] - pub parallelism: Option, - #[serde(default)] - pub turn_timeout_seconds: Option, - #[serde(default)] - pub relay_url: Option, - #[serde(default)] - pub acp_command: Option, - #[serde(default)] - pub agent_command: Option, - #[serde(default)] - pub agent_args: Option>, - #[serde(default)] - pub mcp_command: Option, - /// Absent = don't touch. null = clear to runtime default. "id" = set. - #[serde(default, deserialize_with = "crate::util::double_option")] - pub provider: Option>, - /// Absent = don't touch. Present = set mode. - #[serde(default)] - pub respond_to: Option, - /// Absent = don't touch. Present = replace the allowlist (validated & - /// normalized server-side). - #[serde(default)] - pub respond_to_allowlist: Option>, -} - #[derive(Debug, Serialize)] pub struct UpdateManagedAgentResponse { pub agent: ManagedAgentSummary, @@ -555,6 +673,9 @@ pub struct TeamRecord { pub id: String, pub name: String, pub description: Option, + /// Runtime-layered instructions shared by every member deployment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, pub persona_ids: Vec, #[serde(default)] pub is_builtin: bool, @@ -579,6 +700,7 @@ pub struct TeamRecord { pub struct CreateTeamRequest { pub name: String, pub description: Option, + pub instructions: Option, #[serde(default)] pub persona_ids: Vec, } @@ -589,28 +711,11 @@ pub struct UpdateTeamRequest { pub id: String, pub name: String, pub description: Option, + pub instructions: Option, #[serde(default)] pub persona_ids: Vec, } -/// Result of syncing a directory-backed team with its backing directory. -#[derive(Debug, Clone, Serialize)] -pub struct SyncResult { - pub personas_added: Vec, - pub personas_removed: Vec, - pub personas_updated: Vec, - pub metadata_changed: bool, -} - -/// Report from the one-time packs→teams migration. -#[derive(Debug, Clone, Serialize)] -pub struct MigrationReport { - pub packs_migrated: usize, - pub personas_updated: usize, - pub agents_updated: usize, - pub errors: Vec, -} - pub const DEFAULT_ACP_COMMAND: &str = "buzz-acp"; /// ~5 min (320s) — matches the CLI harness default (BUZZ_ACP_IDLE_TIMEOUT). pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320; @@ -626,6 +731,10 @@ fn default_start_on_app_launch() -> bool { true } +fn default_auto_restart_on_config_change() -> bool { + true +} + fn default_record_active() -> bool { true } @@ -663,6 +772,22 @@ impl RespondTo { Self::Anyone => "anyone", } } + + /// Parse the NIP-AP wire string. Definitions carry `respond_to` as + /// opaque data everywhere else; this is the single parse boundary + /// (instance mint), and an unrecognized mode fails LOUDLY here rather + /// than silently defaulting — a typo'd definition must not mint an + /// agent with a different audience than its author intended. + pub fn parse_wire(value: &str) -> Result { + match value { + "owner-only" => Ok(Self::OwnerOnly), + "allowlist" => Ok(Self::Allowlist), + "anyone" => Ok(Self::Anyone), + other => Err(format!( + "definition respond_to '{other}' is not a recognized mode (expected 'owner-only', 'allowlist', or 'anyone')" + )), + } + } } /// Validate and normalize a respond-to allowlist. @@ -692,5 +817,91 @@ pub fn validate_respond_to_allowlist(input: &[String]) -> Result, St Ok(out) } +/// The behavioral fields resolved for a new instance at mint time. +#[derive(Debug, PartialEq, Eq)] +pub struct MintBehavioralDefaults { + pub respond_to: RespondTo, + pub respond_to_allowlist: Vec, + /// Validated (1..=32) when present; caller applies its own default. + pub parallelism: Option, +} + +/// Resolve the NIP-AP behavioral quad for a new instance: explicit input +/// wins, then the linked definition's defaults, then client defaults. +/// +/// This is the ONLY place definition behavioral strings are parsed — an +/// unrecognized `respond_to` mode or out-of-range `parallelism` on a +/// definition fails the mint loudly instead of silently substituting a +/// default the definition author did not choose. The empty-allowlist guard +/// fires here too, because inbound definitions bypass the dialog entirely. +/// +/// `input_allowlist` must already be normalized via +/// [`validate_respond_to_allowlist`]; the definition's allowlist is +/// validated here since it arrives from the wire. +pub fn resolve_mint_behavioral_defaults( + input_respond_to: Option, + input_allowlist: Vec, + input_parallelism: Option, + definition: Option<&AgentDefinition>, +) -> Result { + let (respond_to, respond_to_allowlist) = match input_respond_to { + // Explicit instance-level choice: the definition default is ignored + // wholesale (mode AND list travel together). + Some(mode) => (mode, input_allowlist), + None => match definition.and_then(|d| d.respond_to.as_deref()) { + Some(wire) => { + let mode = RespondTo::parse_wire(wire)?; + let list = if input_allowlist.is_empty() { + validate_respond_to_allowlist( + definition + .map(|d| d.respond_to_allowlist.as_slice()) + .unwrap_or(&[]), + ) + .map_err(|e| format!("definition respond-to allowlist is invalid: {e}"))? + } else { + input_allowlist + }; + (mode, list) + } + None => (RespondTo::default(), input_allowlist), + }, + }; + if respond_to == RespondTo::Allowlist && respond_to_allowlist.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let parallelism = match input_parallelism { + // Explicit input is validated here too (not just at the command + // call sites) so the "validated when present" contract on + // `MintBehavioralDefaults.parallelism` is unskippable. + Some(count) if (1..=32).contains(&count) => Some(count), + Some(count) => { + return Err(format!( + "parallelism {count} is out of range (must be between 1 and 32)" + )) + } + None => match definition.and_then(|d| d.parallelism) { + Some(count) if (1..=32).contains(&count) => Some(count), + Some(count) => { + return Err(format!( + "parallelism {count} on the linked agent definition is out of range (must be between 1 and 32)" + )) + } + None => None, + }, + }; + + Ok(MintBehavioralDefaults { + respond_to, + respond_to_allowlist, + parallelism, + }) +} + +mod requests; +pub use requests::*; + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs new file mode 100644 index 0000000000..58d60218a1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -0,0 +1,431 @@ +//! Persona and managed-agent command request types, split from `types.rs` +//! (file-size cap). + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use super::{ + default_start_on_app_launch, validate_respond_to_allowlist, AgentDefinition, BackendKind, + RelayMeshConfig, RespondTo, +}; + +/// The NIP-AP behavioral group as one grouped request field. +/// +/// Grouped (not flat) because `update_persona` has legacy callers that don't +/// send behavioral fields at all — flat replace semantics would silently wipe +/// a stored behavior group on every team-import edit. Absent group = don't touch the +/// stored behavior group; present group = validate and replace the fields as a unit +/// (mode and allowlist must travel together). +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonaBehaviorRequest { + #[serde(default)] + pub respond_to: Option, + #[serde(default)] + pub respond_to_allowlist: Vec, + #[serde(default)] + pub parallelism: Option, +} + +/// Validate a behavior group and apply it onto a persona record. +/// +/// This is the single write path for definition behavioral fields — both +/// `create_persona` and `update_persona` route through it, so neither can +/// skip validation. `None` leaves the record's stored behavior group untouched (the +/// legacy-caller wipe hazard); `Some` normalizes the allowlist +/// (`validate_respond_to_allowlist`), rejects allowlist mode with an empty +/// list (the spawn-time crash-loop `build_respond_to_env` errors on), rejects +/// out-of-range parallelism, and stores the behavior group in wire shape. +pub fn apply_persona_behavior( + record: &mut AgentDefinition, + behavior: Option, +) -> Result<(), String> { + let Some(behavior) = behavior else { + return Ok(()); + }; + + let allowlist = validate_respond_to_allowlist(&behavior.respond_to_allowlist)?; + if behavior.respond_to == Some(RespondTo::Allowlist) && allowlist.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + if let Some(count) = behavior.parallelism { + if !(1..=32).contains(&count) { + return Err(format!( + "parallelism {count} is out of range (must be between 1 and 32)" + )); + } + } + + record.respond_to = behavior.respond_to.map(|mode| mode.as_str().to_string()); + // The allowlist only means something in allowlist mode; storing it for + // other modes would republish stale pubkeys the author didn't choose. + record.respond_to_allowlist = if behavior.respond_to == Some(RespondTo::Allowlist) { + allowlist + } else { + Vec::new() + }; + record.parallelism = behavior.parallelism; + Ok(()) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePersonaRequest { + pub display_name: String, + pub avatar_url: Option, + pub system_prompt: String, + #[serde(default)] + pub runtime: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub name_pool: Vec, + /// Environment variables for agents created from this persona. + #[serde(default)] + pub env_vars: BTreeMap, + /// NIP-AP behavioral group. Absent = behavior group stays unset. + #[serde(default)] + pub behavior: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePersonaRequest { + pub id: String, + pub display_name: String, + pub avatar_url: Option, + pub system_prompt: String, + #[serde(default)] + pub runtime: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub name_pool: Vec, + /// Environment variables for agents created from this persona. + /// + /// Absent (`None`) = don't touch the stored value (caller didn't include + /// the field). `Some(map)` = replace entirely (empty map clears all). + /// Defaulting an omitted field to an empty map would silently erase + /// stored credentials when an unrelated field is edited. + #[serde(default)] + pub env_vars: Option>, + /// NIP-AP behavioral group. Same absent-vs-present contract as `env_vars`: + /// absent = don't touch the stored behavior group (legacy callers don't send it), + /// present = validate and replace the fields as a unit. + #[serde(default)] + pub behavior: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateManagedAgentRequest { + pub name: String, + #[serde(default)] + pub persona_id: Option, + /// Optional deployment-time team binding for runtime instruction layering. + #[serde(default)] + pub team_id: Option, + pub relay_url: Option, + pub acp_command: Option, + pub agent_command: Option, + /// True when `agent_command` is a runtime command the user deliberately + /// picked for a linked persona. Distinguishes a real selection, including an + /// installed alias, from a missing-runtime fallback so a persona-backed + /// create only stores an `agent_command_override` for the former. + #[serde(default)] + pub harness_override: bool, + #[serde(default)] + pub agent_args: Vec, + /// Accepted for wire compatibility; not applied to the record. The + /// effective MCP command is always derived from the runtime catalog at + /// spawn time — a per-record override is never read. + /// + /// @deprecated — sending this field has no effect. + #[allow(dead_code)] + pub mcp_command: Option, + /// Accepted for wire compatibility; not applied to the record. + /// `BUZZ_ACP_TURN_TIMEOUT` is deprecated and ignored by the harness. + /// + /// @deprecated — sending this field has no effect. + #[allow(dead_code)] + pub turn_timeout_seconds: Option, + pub idle_timeout_seconds: Option, + pub max_turn_duration_seconds: Option, + pub parallelism: Option, + pub system_prompt: Option, + pub avatar_url: Option, + pub model: Option, + pub provider: Option, + /// Environment variables for this agent. Layered on top of persona env. + #[serde(default)] + pub env_vars: BTreeMap, + #[serde(default)] + pub spawn_after_create: bool, + #[serde(default = "default_start_on_app_launch")] + pub start_on_app_launch: bool, + #[serde(default)] + pub backend: BackendKind, + /// `None` = caller expressed no preference: the definition's + /// `respond_to` default applies when linked, `RespondTo::default()` + /// otherwise. `Some` is an explicit instance-level choice and always + /// wins over the definition default. + #[serde(default)] + pub respond_to: Option, + /// Raw allowlist as received from the frontend. Validated and normalized + /// before being written to the record. + #[serde(default)] + pub respond_to_allowlist: Vec, + #[serde(default)] + pub relay_mesh: Option, +} + +/// Patch request for updating a managed agent's mutable fields. +/// +/// Tri-state nullable semantics via `Option>`: +/// - Field absent in JSON → `None` (don't touch) +/// - `"field": null` → `Some(None)` (clear to default) +/// - `"field": "value"` → `Some(Some("value"))` (set) +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateManagedAgentRequest { + pub pubkey: String, + /// Absent = don't touch. Present = rename the agent. + #[serde(default)] + pub name: Option, + /// Absent = don't touch. null = clear to agent default. "id" = set. + #[serde(default)] + pub model: Option>, + #[serde(default)] + pub system_prompt: Option>, + /// Absent = don't touch. Present = replace the env_vars map entirely. + #[serde(default)] + pub env_vars: Option>, + #[serde(default)] + pub parallelism: Option, + /// Accepted for wire compatibility; not applied to the stored record. + /// `BUZZ_ACP_TURN_TIMEOUT` is deprecated and ignored by the harness. + /// + /// @deprecated — sending this field has no effect. + #[allow(dead_code)] + #[serde(default)] + pub turn_timeout_seconds: Option, + #[serde(default)] + pub relay_url: Option, + #[serde(default)] + pub acp_command: Option, + #[serde(default)] + pub agent_command: Option, + /// True when the accompanying `agent_command` is a runtime/Custom command + /// the user deliberately picked for a linked persona (i.e. the dialog is + /// not inheriting). Distinguishes a real pin — including one that maps to + /// the persona's own runtime — from a persona-authoritative restatement, + /// so a same-runtime pick is preserved instead of being dropped back to + /// inherit. Ignored when `agent_command` is absent or the inherit sentinel. + #[serde(default)] + pub harness_override: bool, + #[serde(default)] + pub agent_args: Option>, + /// Accepted for wire compatibility; not applied to the stored record. + /// The effective MCP command is always catalog-derived at spawn time. + /// + /// @deprecated — sending this field has no effect. + #[allow(dead_code)] + #[serde(default)] + pub mcp_command: Option, + /// Absent = don't touch. null = clear to runtime default. "id" = set. + #[serde(default, deserialize_with = "crate::util::double_option")] + pub provider: Option>, + /// Absent = don't touch. Present = set mode. + #[serde(default)] + pub respond_to: Option, + /// Absent = don't touch. Present = replace the allowlist (validated & + /// normalized server-side). + #[serde(default)] + pub respond_to_allowlist: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record_with_quad() -> AgentDefinition { + let mut record = record_without_quad(); + record.respond_to = Some("allowlist".to_string()); + record.respond_to_allowlist = vec!["a".repeat(64)]; + record.parallelism = Some(4); + record + } + + fn record_without_quad() -> AgentDefinition { + AgentDefinition { + id: "p-1".to_string(), + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "prompt".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } + } + + /// The anchor regression row: an absent behavior group must leave a + /// stored behavior group untouched — legacy update_persona callers (team import, + /// profile panel) send no behavior field and must not wipe it. + #[test] + fn absent_behavior_leaves_stored_quad_untouched() { + let mut record = record_with_quad(); + apply_persona_behavior(&mut record, None).unwrap(); + assert_eq!(record.respond_to.as_deref(), Some("allowlist")); + assert_eq!(record.respond_to_allowlist, vec!["a".repeat(64)]); + assert_eq!(record.parallelism, Some(4)); + } + + #[test] + fn present_behavior_replaces_all_four_as_a_unit() { + let mut record = record_with_quad(); + apply_persona_behavior( + &mut record, + Some(PersonaBehaviorRequest { + respond_to: Some(RespondTo::Anyone), + respond_to_allowlist: Vec::new(), + parallelism: None, + }), + ) + .unwrap(); + assert_eq!(record.respond_to.as_deref(), Some("anyone")); + assert!(record.respond_to_allowlist.is_empty()); + assert_eq!(record.parallelism, None); + } + + #[test] + fn allowlist_mode_with_empty_list_is_rejected() { + let mut record = record_without_quad(); + let err = apply_persona_behavior( + &mut record, + Some(PersonaBehaviorRequest { + respond_to: Some(RespondTo::Allowlist), + respond_to_allowlist: Vec::new(), + ..Default::default() + }), + ) + .unwrap_err(); + assert!(err.contains("allowlist"), "{err}"); + // Rejection must not half-apply: the record stays untouched. + assert_eq!(record.respond_to, None); + } + + #[test] + fn allowlist_entries_are_normalized_via_the_shared_validator() { + let mut record = record_without_quad(); + let upper = "A".repeat(64); + apply_persona_behavior( + &mut record, + Some(PersonaBehaviorRequest { + respond_to: Some(RespondTo::Allowlist), + respond_to_allowlist: vec![upper.clone(), upper], + ..Default::default() + }), + ) + .unwrap(); + // Lowercased and deduplicated, matching the instance-side chokepoint. + assert_eq!(record.respond_to_allowlist, vec!["a".repeat(64)]); + } + + #[test] + fn invalid_allowlist_entry_is_rejected() { + let mut record = record_without_quad(); + let err = apply_persona_behavior( + &mut record, + Some(PersonaBehaviorRequest { + respond_to: Some(RespondTo::Allowlist), + respond_to_allowlist: vec!["not-hex".to_string()], + ..Default::default() + }), + ) + .unwrap_err(); + assert!(err.contains("64 hex"), "{err}"); + } + + #[test] + fn allowlist_is_dropped_for_non_allowlist_modes() { + let mut record = record_without_quad(); + apply_persona_behavior( + &mut record, + Some(PersonaBehaviorRequest { + respond_to: Some(RespondTo::OwnerOnly), + respond_to_allowlist: vec!["b".repeat(64)], + ..Default::default() + }), + ) + .unwrap(); + assert!( + record.respond_to_allowlist.is_empty(), + "stale pubkeys must not be stored alongside a non-allowlist mode" + ); + } + + /// Pinky's loop row: an applied behavior group must flow through + /// `persona_event_content` so the republished 30175 carries the edited + /// behavior group — the write path and the publish path cannot drift apart. + #[test] + fn applied_behavior_flows_into_persona_event_content() { + let mut record = record_without_quad(); + apply_persona_behavior( + &mut record, + Some(PersonaBehaviorRequest { + respond_to: Some(RespondTo::Allowlist), + respond_to_allowlist: vec!["c".repeat(64)], + parallelism: Some(3), + }), + ) + .unwrap(); + let content = crate::managed_agents::persona_events::persona_event_content(&record); + assert_eq!(content.respond_to.as_deref(), Some("allowlist")); + assert_eq!(content.respond_to_allowlist, vec!["c".repeat(64)]); + assert_eq!(content.parallelism, Some(3)); + } + + #[test] + fn parallelism_out_of_range_is_rejected() { + let mut record = record_without_quad(); + for bad in [0u32, 33] { + let err = apply_persona_behavior( + &mut record, + Some(PersonaBehaviorRequest { + parallelism: Some(bad), + ..Default::default() + }), + ) + .unwrap_err(); + assert!(err.contains("out of range"), "{err}"); + } + + apply_persona_behavior( + &mut record, + Some(PersonaBehaviorRequest { + parallelism: Some(8), + ..Default::default() + }), + ) + .unwrap(); + assert_eq!(record.parallelism, Some(8)); + } +} diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 37404fb593..667a41a538 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -1,9 +1,9 @@ -use super::{ManagedAgentRecord, PersonaRecord}; +use super::{AgentDefinition, ManagedAgentRecord}; use std::path::PathBuf; #[test] fn persona_record_defaults_active_when_field_is_missing() { - let record: PersonaRecord = serde_json::from_str( + let record: AgentDefinition = serde_json::from_str( r#"{ "id": "builtin:fizz", "display_name": "Fizz", @@ -291,7 +291,7 @@ fn relay_mesh_config_round_trips_snake_case() { #[test] fn persona_record_deserializes_old_source_pack_fields_via_alias() { - let record: PersonaRecord = serde_json::from_str( + let record: AgentDefinition = serde_json::from_str( r#"{ "id": "persona-1", "display_name": "Test", @@ -314,7 +314,7 @@ fn persona_record_deserializes_old_source_pack_fields_via_alias() { #[test] fn persona_record_serializes_new_field_names() { - let record: PersonaRecord = serde_json::from_str( + let record: AgentDefinition = serde_json::from_str( r#"{ "id": "persona-1", "display_name": "Test", @@ -467,3 +467,185 @@ fn sample_agent_record() -> ManagedAgentRecord { ) .expect("sample record") } + +// ── AgentDefinition ↔ ManagedAgentRecord fold mapping (Phase 1A) ───────────────────── + +fn sample_persona() -> AgentDefinition { + AgentDefinition { + id: "custom:helper".to_string(), + display_name: "Helper".to_string(), + avatar_url: Some("https://example.com/a.png".to_string()), + system_prompt: "You help.".to_string(), + runtime: Some("goose".to_string()), + model: Some("gpt-x".to_string()), + provider: Some("openai".to_string()), + name_pool: vec!["Nimble".to_string()], + is_builtin: false, + is_active: true, + source_team: Some("team-1".to_string()), + source_team_persona_slug: Some("helper".to_string()), + env_vars: [("K".to_string(), "v".to_string())].into_iter().collect(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + } +} + +#[test] +fn persona_into_agent_record_is_keyless_and_slugged() { + let record = sample_persona().into_agent_record(); + assert!(record.pubkey.is_empty(), "fold must not mint identity"); + assert!(record.private_key_nsec.is_empty()); + assert_eq!(record.slug.as_deref(), Some("custom:helper")); + assert_eq!(record.display_name.as_deref(), Some("Helper")); + assert_eq!(record.system_prompt.as_deref(), Some("You help.")); + assert_eq!(record.runtime.as_deref(), Some("goose")); + assert_eq!(record.source_team.as_deref(), Some("team-1")); + assert_eq!(record.env_vars.get("K").map(String::as_str), Some("v")); +} + +#[test] +fn persona_view_round_trips_through_agent_record() { + let persona = sample_persona(); + let view = persona + .clone() + .into_agent_record() + .to_definition_view() + .expect("slugged record must present a persona view"); + assert_eq!( + serde_json::to_value(&view).unwrap(), + serde_json::to_value(&persona).unwrap(), + "fold + view must round-trip every persona field" + ); +} + +#[test] +fn keyed_record_without_slug_has_no_persona_view() { + let mut record = sample_persona().into_agent_record(); + record.slug = None; + assert!( + record.to_definition_view().is_none(), + "instances (no slug) are not definitions" + ); +} + +#[test] +fn empty_prompt_folds_to_none() { + let mut persona = sample_persona(); + persona.system_prompt = String::new(); + assert_eq!(persona.into_agent_record().system_prompt, None); +} + +// ── Mint-time behavioral defaults (B5 quad activation) ────────────────────── + +use super::resolve_mint_behavioral_defaults; + +fn quad_definition(respond_to: &str, allowlist: Vec<&str>) -> AgentDefinition { + let mut persona = sample_persona(); + persona.respond_to = Some(respond_to.to_string()); + persona.respond_to_allowlist = allowlist.into_iter().map(str::to_string).collect(); + persona.parallelism = Some(8); + persona +} + +#[test] +fn mint_explicit_input_wins_over_definition() { + let definition = quad_definition("anyone", vec![]); + let minted = resolve_mint_behavioral_defaults( + Some(RespondTo::OwnerOnly), + Vec::new(), + Some(2), + Some(&definition), + ) + .unwrap(); + assert_eq!(minted.respond_to, RespondTo::OwnerOnly); + assert_eq!(minted.parallelism, Some(2)); +} + +#[test] +fn mint_copies_definition_quad_when_input_silent() { + let allow = "a".repeat(64); + let definition = quad_definition("allowlist", vec![&allow]); + let minted = + resolve_mint_behavioral_defaults(None, Vec::new(), None, Some(&definition)).unwrap(); + assert_eq!(minted.respond_to, RespondTo::Allowlist); + assert_eq!(minted.respond_to_allowlist, vec![allow]); + assert_eq!(minted.parallelism, Some(8)); +} + +#[test] +fn mint_without_definition_or_input_uses_client_defaults() { + let minted = resolve_mint_behavioral_defaults(None, Vec::new(), None, None).unwrap(); + assert_eq!(minted.respond_to, RespondTo::default()); + assert!(minted.respond_to_allowlist.is_empty()); + assert_eq!(minted.parallelism, None); +} + +#[test] +fn mint_fails_loudly_on_unknown_definition_respond_to() { + // A typo'd mode must never silently become owner-only — the definition + // author intended SOMETHING, and guessing which thing is the one wrong + // move. The error must carry the offending string. + let definition = quad_definition("allowlst", vec![]); + let err = + resolve_mint_behavioral_defaults(None, Vec::new(), None, Some(&definition)).unwrap_err(); + assert!( + err.contains("allowlst"), + "error must name the bad mode: {err}" + ); +} + +#[test] +fn mint_fails_loudly_on_empty_definition_allowlist() { + // Inbound definitions bypass the dialog guard entirely — the mint + // boundary is the backstop against a crash-looping instance. + let definition = quad_definition("allowlist", vec![]); + let err = + resolve_mint_behavioral_defaults(None, Vec::new(), None, Some(&definition)).unwrap_err(); + assert!( + err.contains("at least one pubkey"), + "unexpected error: {err}" + ); +} + +#[test] +fn mint_fails_loudly_on_out_of_range_definition_parallelism() { + let mut definition = quad_definition("anyone", vec![]); + definition.parallelism = Some(64); + let err = + resolve_mint_behavioral_defaults(None, Vec::new(), None, Some(&definition)).unwrap_err(); + assert!(err.contains("64"), "error must name the bad value: {err}"); +} + +#[test] +fn mint_normalizes_definition_allowlist_from_wire() { + let upper = "A".repeat(64); + let definition = quad_definition("allowlist", vec![&upper]); + let minted = + resolve_mint_behavioral_defaults(None, Vec::new(), None, Some(&definition)).unwrap(); + assert_eq!(minted.respond_to_allowlist, vec!["a".repeat(64)]); +} + +#[test] +fn mint_resolves_each_behavioral_field_independently() { + // PR #1667 review (convergent): the input-wins rule is per-FIELD, not + let definition = quad_definition("anyone", vec![]); + let minted = + resolve_mint_behavioral_defaults(None, Vec::new(), None, Some(&definition)).unwrap(); + assert_eq!(minted.respond_to, RespondTo::Anyone, "inherited"); + assert_eq!(minted.parallelism, Some(8), "inherited"); +} + +#[test] +fn mint_rejects_out_of_range_input_parallelism() { + // The "validated when present" contract on MintBehavioralDefaults holds + // for the INPUT branch too, not just definition values. + let err = resolve_mint_behavioral_defaults(None, Vec::new(), Some(64), None).unwrap_err(); + assert!(err.contains("64"), "error must name the bad value: {err}"); + assert!( + !err.contains("definition"), + "input-branch error must not blame the definition: {err}" + ); +} diff --git a/desktop/src-tauri/src/media_proxy.rs b/desktop/src-tauri/src/media_proxy.rs index 10a02d947e..760111cdea 100644 --- a/desktop/src-tauri/src/media_proxy.rs +++ b/desktop/src-tauri/src/media_proxy.rs @@ -11,6 +11,7 @@ use tauri::{http, Manager}; use tokio::net::TcpListener; use crate::app_state::AppState; +use crate::commands::media::mint_media_get_auth; use crate::relay; /// Defense-in-depth cap: refuse to buffer responses larger than this into RAM. @@ -56,6 +57,12 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> .get(&upstream_url) .timeout(std::time::Duration::from_secs(120)); + // `upstream_url` is always `{relay base}{path}`, so the token can't reach + // a third-party origin (mint_media_get_auth safety contract). + if let Some(auth) = mint_media_get_auth(&app_state, &base_url) { + upstream = upstream.header("authorization", auth); + } + if let Some(range) = req.headers().get("range") { if let Ok(v) = range.to_str() { upstream = upstream.header("range", v); @@ -177,6 +184,13 @@ pub async fn handle_buzz_media( .http_client .get(&upstream_url) .timeout(std::time::Duration::from_secs(60)); + + // `upstream_url` is always `{relay base}{path}`, so the token can't reach + // a third-party origin (mint_media_get_auth safety contract). + if let Some(auth) = mint_media_get_auth(&state, &base) { + upstream = upstream.header("authorization", auth); + } + if let Some(range) = request.headers().get("range") { if let Ok(v) = range.to_str() { upstream = upstream.header("range", v); diff --git a/desktop/src-tauri/src/mesh_llm/catalog.rs b/desktop/src-tauri/src/mesh_llm/catalog.rs new file mode 100644 index 0000000000..d5fd91b905 --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/catalog.rs @@ -0,0 +1,231 @@ +//! Hardware-aware model catalog for the Share-compute picker. +//! +//! Same diagnose pattern as mesh-console: survey the machine's AI memory, +//! rank mesh-llm's curated `MODEL_CATALOG` by how each model fits, mark what +//! is already in the HuggingFace cache, and recommend a best fit. This +//! replaces guessing into a free-text model field. + +use serde::Serialize; + +use mesh_llm_client::models::catalog::{parse_size_gb, MODEL_CATALOG}; +use mesh_llm_client::network::nostr::auto_model_pack; +use mesh_llm_node::models::{default_huggingface_cache_dir, scan_installed_models}; +use mesh_llm_system::hardware; +use mesh_llm_system::vram::format_rated_capacity; + +/// How a model sits inside this machine's usable AI memory. +/// Mirrors mesh-llm's private `fit_code_for_size_label` thresholds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ModelFit { + Comfortable, + Tight, + Tradeoff, + TooLarge, +} + +fn fit_code(model_gb: f64, vram_gb: f64) -> ModelFit { + if model_gb <= vram_gb * 0.6 { + ModelFit::Comfortable + } else if model_gb <= vram_gb * 0.9 { + ModelFit::Tight + } else if model_gb <= vram_gb * 1.1 { + ModelFit::Tradeoff + } else { + ModelFit::TooLarge + } +} + +fn fit_rank(fit: ModelFit) -> u8 { + match fit { + ModelFit::Comfortable => 0, + ModelFit::Tight => 1, + ModelFit::Tradeoff => 2, + ModelFit::TooLarge => 3, + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MeshCatalogEntry { + /// Catalog name — what the user serves (goes straight into the model field). + pub name: String, + /// Display size, e.g. "5.0GB". + pub size: String, + pub size_gb: f64, + pub description: String, + pub fit: ModelFit, + pub installed: bool, + pub recommended: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MeshModelCatalog { + /// e.g. "Apple M3 Max" + pub gpu_name: Option, + /// Usable AI memory, display-formatted (e.g. "96 GB"). + pub vram_display: String, + pub vram_gb: f64, + /// Best-fit catalog name for this hardware, if any. + pub recommended: Option, + /// Ranked: recommended first, then by fit, then larger first within a fit. + pub entries: Vec, +} + +/// Survey hardware and rank the curated catalog for this machine. +/// Draft (speculative-decoding) models are excluded — they are not something +/// a person shares directly. +pub fn model_catalog() -> MeshModelCatalog { + let survey = hardware::survey(); + let vram_gb = survey.vram_bytes as f64 / 1e9; + build_catalog( + survey.gpu_name.clone(), + survey.vram_bytes, + vram_gb, + &installed_names(), + ) +} + +fn installed_names() -> Vec<(String, String)> { + let cache = default_huggingface_cache_dir(); + scan_installed_models(cache) + .into_iter() + .map(|m| { + let file = m + .path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or_default() + .to_string(); + (file, m.model_ref) + }) + .collect() +} + +fn build_catalog( + gpu_name: Option, + vram_bytes: u64, + vram_gb: f64, + installed: &[(String, String)], +) -> MeshModelCatalog { + let is_installed = |file: &str, name: &str| { + installed + .iter() + .any(|(f, model_ref)| f == file || model_ref.contains(name)) + }; + let mut entries: Vec = MODEL_CATALOG + .iter() + .filter(|m| !is_draft_only(&m.name)) + .map(|m| { + let size_gb = parse_size_gb(&m.size); + MeshCatalogEntry { + fit: fit_code(size_gb, vram_gb), + installed: is_installed(&m.file, &m.name), + recommended: false, + name: m.name.clone(), + size: m.size.clone(), + size_gb, + description: m.description.clone(), + } + }) + .collect(); + + let recommended = auto_model_pack(vram_gb).into_iter().next(); + for entry in &mut entries { + entry.recommended = recommended.as_deref() == Some(entry.name.as_str()); + } + + entries.sort_by(|a, b| { + b.recommended + .cmp(&a.recommended) + .then(fit_rank(a.fit).cmp(&fit_rank(b.fit))) + .then(b.size_gb.total_cmp(&a.size_gb)) + }); + + MeshModelCatalog { + gpu_name, + vram_display: format_rated_capacity(vram_bytes), + vram_gb, + recommended, + entries, + } +} + +/// A model that exists in the catalog only as another model's draft +/// (speculative decoding helper) — identified by being referenced in any +/// `draft` field. People share chat models, not drafts. +fn is_draft_only(name: &str) -> bool { + MODEL_CATALOG + .iter() + .any(|m| m.draft.as_deref() == Some(name)) + && !MODEL_CATALOG + .iter() + .any(|m| m.name == name && m.draft.is_some()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fit_thresholds_match_mesh_llm() { + // 10GB model on various machines. Thresholds are 0.6 / 0.9 / 1.1. + assert_eq!(fit_code(10.0, 20.0), ModelFit::Comfortable); + assert_eq!(fit_code(10.0, 12.0), ModelFit::Tight); + assert_eq!(fit_code(10.0, 10.0), ModelFit::Tradeoff); + assert_eq!(fit_code(10.0, 8.0), ModelFit::TooLarge); + } + + #[test] + fn catalog_ranks_recommended_first_then_fit() { + let catalog = build_catalog(Some("Test GPU".into()), 24_000_000_000, 24.0, &[]); + assert!( + !catalog.entries.is_empty(), + "curated catalog must not be empty" + ); + // The recommended entry (if present in the catalog) must be first. + if let Some(recommended) = &catalog.recommended { + if catalog.entries.iter().any(|e| &e.name == recommended) { + assert_eq!(&catalog.entries[0].name, recommended); + assert!(catalog.entries[0].recommended); + } + } + // Fit ranks must be non-decreasing after the recommended head. + let ranks: Vec = catalog + .entries + .iter() + .skip_while(|e| e.recommended) + .map(|e| fit_rank(e.fit)) + .collect(); + assert!( + ranks.windows(2).all(|w| w[0] <= w[1]), + "fit ranks out of order: {ranks:?}" + ); + } + + #[test] + fn recommendation_uses_mesh_llm_auto_selection() { + let catalog = build_catalog(None, 62_000_000_000, 62.0, &[]); + assert_eq!( + catalog.recommended, + auto_model_pack(62.0).into_iter().next() + ); + } + + #[test] + fn installed_matches_by_file_or_model_ref() { + let installed = vec![( + "Qwen3-8B-Q4_K_M.gguf".to_string(), + "unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(), + )]; + let catalog = build_catalog(None, 96_000_000_000, 96.0, &installed); + let qwen8b = catalog.entries.iter().find(|e| e.name == "Qwen3-8B-Q4_K_M"); + if let Some(entry) = qwen8b { + assert!(entry.installed, "cached file must mark entry installed"); + } + // A machine with nothing installed marks nothing installed. + let empty = build_catalog(None, 96_000_000_000, 96.0, &[]); + assert!(empty.entries.iter().all(|e| !e.installed)); + } +} diff --git a/desktop/src-tauri/src/mesh_llm/coordinator.rs b/desktop/src-tauri/src/mesh_llm/coordinator.rs index d8f01a96f3..8e7b2f9536 100644 --- a/desktop/src-tauri/src/mesh_llm/coordinator.rs +++ b/desktop/src-tauri/src/mesh_llm/coordinator.rs @@ -1,138 +1,107 @@ -//! Rust-owned relay-mesh coordinator. +//! Runtime-owned shared-compute coordinator. //! -//! Owns the control plane that used to live in React (`useMeshRelayOrchestrator`) -//! and the TS start helper (`startRelayMeshClientForTarget`). One runtime-owned -//! actor, started when the desktop's relay identity is set — independent of any -//! UI mount or mesh activity. This is what kills the cold-launch race: the -//! call-me-now listener's lifetime is tied to the runtime, not to a renderer -//! mounting with a pubkey. -//! -//! Two responsibilities: -//! 1. `spawn_listener` — a long-lived task that holds an authenticated WS to -//! Buzz's relay (generalizing the proven `commands::pairing` NIP-42 -//! machinery), subscribes `kind:24622 #p=self`, and dials each paired -//! call-me-now back into the local mesh runtime. Idempotent: one listener -//! per process; re-entrant calls return the live handle. -//! 2. `start_client` — publishes a `kind:24621` connect-request with a fresh -//! attempt id and drives bounded publish+dial retry inside the relay's 60s -//! call-me-now TTL. Both fresh-create and saved/restore start paths route -//! through here, so there is no behavioral fork. -//! -//! `start_client` blocks on `listener_active` being `true` before publishing, -//! so a 24621 can never be emitted before this desktop is able to receive its -//! own paired 24622. The race is closed by construction, not convention. +//! Buzz publishes a client-signed, replaceable discovery note containing the +//! member's MeshLLM owner identity and current iroh endpoint. MeshLLM itself +//! performs transport (direct QUIC or its encrypted iroh relays) and admission. +//! The Buzz relay is only a generic Nostr store for membership and discovery; +//! it does not coordinate connections or require mesh-specific handlers. use std::time::Duration; -use futures_util::{SinkExt, StreamExt}; -use nostr::JsonUtil; -use serde_json::json; +use nostr::Tag; use tauri::{AppHandle, Manager}; -use tokio::sync::watch; -use tokio_tungstenite::{connect_async, tungstenite::Message}; - -use buzz_core_pkg::kind::{ - KIND_MESH_CALL_ME_NOW, KIND_MESH_CONNECT_REQUEST, KIND_MESH_STATUS_REPORT, -}; use crate::app_state::AppState; -/// Relay's call-me-now TTL. Retry deadline lives inside this window: once the -/// paired 24622 expires, re-dialing the same attempt is pointless. -const CALL_ME_NOW_TTL: Duration = Duration::from_secs(60); -/// Backoff between connect-request attempts. Hole-punch is lossy; a single -/// publish is flaky by construction even when both ends are correct. -const RETRY_BACKOFF: Duration = Duration::from_secs(5); +/// Client-owned parameterized-replaceable discovery note. We use the standard +/// NIP-51 bookmark-set kind with a reserved d-tag so existing Buzz relays accept +/// and store it through their generic user-state path. The relay needs no mesh +/// handler or kind-registry change. +pub const KIND_BUZZ_MESH_MEMBER_STATUS: u16 = buzz_core_pkg::kind::KIND_BOOKMARK_SET as u16; +const STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status"; +const ROSTER_POLL_INTERVAL: Duration = Duration::from_secs(60); +const STATUS_PUBLISH_INTERVAL: Duration = Duration::from_secs(45); -/// Handle to the runtime-owned mesh control plane. Stored on [`AppState`]. pub struct MeshCoordinator { - /// `true` once the call-me-now listener holds a live, authenticated - /// subscription. `start_client` awaits this before publishing a 24621. - /// Observable so guardrail tests can assert listener-before-publish - /// without depending on wall-clock timing. - listener_active: watch::Receiver, - _listener: tokio::task::JoinHandle<()>, _status_publisher: tokio::task::JoinHandle<()>, + _roster_watcher: tokio::task::JoinHandle<()>, } -impl MeshCoordinator { - /// Whether the call-me-now listener is currently live. Exposed for - /// instrumentation and tests. - pub fn listener_active(&self) -> bool { - *self.listener_active.borrow() - } - - /// Await the listener becoming active, bounded by `timeout`. Returns - /// `Err` if it does not come up in time (relay unreachable / auth failed), - /// so `start_client` fails loudly rather than publishing into the void. - async fn await_listener(&self, timeout: Duration) -> Result<(), String> { - if self.listener_active() { - return Ok(()); - } - let mut rx = self.listener_active.clone(); - tokio::time::timeout(timeout, async { - while !*rx.borrow() { - if rx.changed().await.is_err() { - return Err("mesh listener task ended before becoming active".to_string()); - } - } - Ok(()) - }) - .await - .map_err(|_| "timed out waiting for mesh call-me-now listener to come up".to_string())? - } -} - -/// Start the runtime-owned relay-mesh coordinator if it is not already running. -/// Idempotent: a second call with a coordinator already present is a no-op. -/// -/// Known limitation: the listener subscribes with the identity active at spawn -/// time and is never restarted. If the workspace identity changes mid-session -/// the subscription keeps filtering on the old pubkey; an app restart picks up -/// the new one. Acceptable for now — identity changes are rare and already -/// disruptive — but revisit if identity switching becomes a first-class flow. -/// -/// Spawned at identity-set time from `lib.rs` setup, *before* any restore or -/// create attempt can enqueue a connect-request. Holds the `AppHandle` and -/// fetches `AppState` per session (the codebase manages `AppState` by value; -/// long-lived tasks never hold an `Arc` across awaits). -pub async fn spawn_listener(app: AppHandle) { +/// Start the runtime-owned status publisher and admission-roster watcher. +pub async fn start_coordinator(app: AppHandle) { { let state = app.state::(); - let guard = state.mesh_coordinator.lock().await; - if guard.is_some() { + if state.mesh_coordinator.lock().await.is_some() { return; } } - let (active_tx, active_rx) = watch::channel(false); - let listener_app = app.clone(); - let listener = tokio::spawn(async move { - listener_loop(listener_app, active_tx).await; - }); + let publisher_app = app.clone(); - let publisher = tokio::spawn(async move { - status_publisher_loop(publisher_app).await; + let status_publisher = tokio::spawn(async move { + // Clear a stale serving status promptly after an app restart. Once the + // node is stopped, the explicit stopped event is sufficient; only a + // running node needs periodic freshness heartbeats. + publish_current_status_once(&publisher_app, "startup").await; + loop { + tokio::time::sleep(STATUS_PUBLISH_INTERVAL).await; + publish_running_status_once(&publisher_app).await; + } }); + let roster_app = app.clone(); + let roster_watcher = tokio::spawn(async move { + loop { + tokio::time::sleep(ROSTER_POLL_INTERVAL).await; + let state = roster_app.state::(); + if let Err(error) = reconcile_roster(&state).await { + eprintln!("buzz-mesh: roster reconcile failed: {error}"); + } + } + }); + let state = app.state::(); let mut guard = state.mesh_coordinator.lock().await; if guard.is_none() { *guard = Some(MeshCoordinator { - listener_active: active_rx, - _listener: listener, - _status_publisher: publisher, + _status_publisher: status_publisher, + _roster_watcher: roster_watcher, }); } else { - // Lost a race: another caller installed a coordinator first. Drop ours. - listener.abort(); - publisher.abort(); + status_publisher.abort(); + roster_watcher.abort(); } } -async fn status_publisher_loop(app: AppHandle) { - loop { - publish_current_status_once(&app, "periodic").await; - tokio::time::sleep(Duration::from_secs(15)).await; +async fn reconcile_roster(state: &AppState) -> Result<(), String> { + let current_request = { + let runtime = state.mesh_llm_runtime.lock().await; + match runtime.as_ref() { + Some(runtime) => runtime.start_request().clone(), + None => return Ok(()), + } + }; + let Some(current_owners) = current_request.trusted_owner_ids.as_ref() else { + return Ok(()); + }; + let fresh = crate::commands::mesh_llm::resolve_trusted_owner_ids(state).await; + if &fresh == current_owners { + return Ok(()); } + + let mut request = current_request; + request.trusted_owner_ids = Some(fresh); + let mut guard = state.mesh_llm_runtime.lock().await; + let Some(running) = guard.take() else { + return Ok(()); + }; + eprintln!("buzz-mesh: membership roster changed; restarting mesh node with fresh allowlist"); + if let Err(error) = running.stop().await { + eprintln!("buzz-mesh: stopping mesh node for roster restart failed: {error}"); + } + let replacement = crate::mesh_llm::DesktopMeshRuntime::start(request) + .await + .map_err(|error| format!("mesh node restart after roster change failed: {error}"))?; + *guard = Some(replacement); + Ok(()) } pub(crate) async fn publish_current_status_once(app: &AppHandle, reason: &str) { @@ -149,413 +118,160 @@ pub(crate) async fn publish_stopped_status_once(app: &AppHandle, reason: &str) { } } +async fn publish_running_status_once(app: &AppHandle) { + let state = app.state::(); + if let Err(error) = publish_running_status_for_state(&state).await { + eprintln!("buzz-mesh: periodic status report failed: {error}"); + } +} + async fn publish_current_status_for_state(state: &AppState) -> Result<(), String> { - let payload = { + let identity = super::ensure_owner_identity() + .map_err(|error| format!("failed to load mesh owner identity: {error}"))?; + let mut payload = { let runtime = state.mesh_llm_runtime.lock().await; match runtime.as_ref() { Some(runtime) => runtime .status_report_payload() .await .map_err(|error| error.to_string())?, - None => return Ok(()), + None => stopped_status_payload(&identity), } }; + bind_payload_to_member(state, &identity, &mut payload)?; publish_status_report(state, payload).await } async fn publish_stopped_status_for_state(state: &AppState) -> Result<(), String> { - publish_status_report(state, stopped_status_payload()).await + let identity = super::ensure_owner_identity() + .map_err(|error| format!("failed to load mesh owner identity: {error}"))?; + let mut payload = stopped_status_payload(&identity); + bind_payload_to_member(state, &identity, &mut payload)?; + publish_status_report(state, payload).await } -fn stopped_status_payload() -> serde_json::Value { +async fn publish_running_status_for_state(state: &AppState) -> Result<(), String> { + let identity = super::ensure_owner_identity() + .map_err(|error| format!("failed to load mesh owner identity: {error}"))?; + let mut payload = { + let runtime = state.mesh_llm_runtime.lock().await; + let Some(runtime) = runtime.as_ref() else { + return Ok(()); + }; + runtime + .status_report_payload() + .await + .map_err(|error| error.to_string())? + }; + bind_payload_to_member(state, &identity, &mut payload)?; + publish_status_report(state, payload).await +} + +fn stopped_status_payload(identity: &super::identity::OwnerIdentity) -> serde_json::Value { serde_json::json!({ - "token": "", - "hosted_models": [], - "serving_models": [], - "peers": [], + "ownerId": identity.owner_id, + "ownerVerifyingKey": identity.verifying_key_hex, + "serveTargets": [], + "models": [], }) } -/// The listener task body. Connects, authenticates as the Buzz identity, -/// subscribes `24622 #p=self`, and dials each paired call-me-now. Reconnects -/// with backoff on connection loss; flips `active` to `false` while down so -/// `start_client` won't publish during an outage. -async fn listener_loop(app: AppHandle, active: watch::Sender) { - loop { - if let Err(error) = listener_session(&app, &active).await { - eprintln!("buzz-mesh: call-me-now listener session ended: {error}"); - } - let _ = active.send(false); - tokio::time::sleep(RETRY_BACKOFF).await; - } -} - -/// One authenticated listener session. Mirrors `commands::pairing`'s WS+NIP-42 -/// preamble, then runs a long-lived REQ on `24622 #p=self`. -async fn listener_session(app: &AppHandle, active: &watch::Sender) -> Result<(), String> { - let (relay_url, self_pk) = { - let state = app.state::(); - let url = crate::relay::relay_ws_url_with_override(&state); - let pk = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; - (url, pk) - }; - - let (ws, _) = connect_async(&relay_url) - .await - .map_err(|e| format!("mesh listener WS connect failed: {e}"))?; - let (mut write, mut read) = ws.split(); - - authenticate(app, &relay_url, &mut read, &mut write).await?; - - let sub = json!([ - "REQ", "mesh-call-me-now", - { "kinds": [KIND_MESH_CALL_ME_NOW], "#p": [self_pk], "limit": 0 } - ]); - write - .send(Message::Text(sub.to_string().into())) - .await - .map_err(|e| format!("mesh listener subscribe failed: {e}"))?; - - // Subscription accepted — we can now receive our own paired 24622. Signal - // before processing events so `start_client` may proceed. - let _ = active.send(true); - - while let Some(msg) = read.next().await { - let msg = msg.map_err(|e| format!("mesh listener WS read error: {e}"))?; - let Message::Text(text) = msg else { continue }; - let Some(event) = parse_relay_event(text.as_str(), "mesh-call-me-now") else { - continue; - }; - if let Some(addr) = call_me_now_peer_addr(&event) { - // Dial the peer the relay paired us with. Errors are per-attempt; - // the initiator's retry loop and the peer's own dial cover loss. - let state = app.state::(); - if let Err(error) = - crate::commands::ensure_client_node_for_model_dial_only(&state, &addr).await - { - eprintln!("buzz-mesh: call-me-now dial failed: {error}"); - } - } - } +fn bind_payload_to_member( + state: &AppState, + identity: &super::identity::OwnerIdentity, + payload: &mut serde_json::Value, +) -> Result<(), String> { + let member_pubkey = state.signing_keys()?.public_key().to_hex(); + let endpoint_tokens = super::identity::advertised_endpoint_tokens(payload) + .ok_or_else(|| "mesh discovery status has malformed serveTargets".to_string())?; + payload["ownerId"] = serde_json::Value::String(identity.owner_id.clone()); + payload["ownerVerifyingKey"] = serde_json::Value::String(identity.verifying_key_hex.clone()); + payload["ownerBindingSig"] = serde_json::Value::String( + identity + .sign_member_binding(&member_pubkey) + .map_err(|error| error.to_string())?, + ); + payload["ownerEndpointBindingSig"] = serde_json::Value::String( + identity + .sign_member_endpoint_binding(&member_pubkey, &endpoint_tokens) + .map_err(|error| error.to_string())?, + ); Ok(()) } -pub struct RelayMeshConnectRequest<'a> { - pub target_pubkey: &'a str, - pub peer_endpoint_addr: &'a str, - pub self_endpoint_addr: &'a str, - pub peer_endpoint_id: Option<&'a str>, - pub self_endpoint_id: Option<&'a str>, -} - -/// Publish a `kind:24621` connect-request and drive bounded publish+dial retry. -/// Blocks until the listener is active so we never request a connection we -/// cannot receive the pairing for. One `attempt_id` per call correlates the -/// retries in logs and tests. -pub async fn start_client( - app: &AppHandle, - request: RelayMeshConnectRequest<'_>, -) -> Result { - let attempt_id = uuid::Uuid::new_v4().to_string(); - { - let state = app.state::(); - let guard = state.mesh_coordinator.lock().await; - let coordinator = guard - .as_ref() - .ok_or("mesh coordinator not started; cannot request connection")?; - coordinator.await_listener(Duration::from_secs(10)).await?; - } - - let deadline = tokio::time::Instant::now() + CALL_ME_NOW_TTL; - let mut last_error = String::new(); - while tokio::time::Instant::now() < deadline { - let state = app.state::(); - match publish_connect_request(&state, &request, &attempt_id).await { - Ok(()) => { - // Dial in the same attempt: hole-punch needs both ends dialing. - if let Err(error) = crate::commands::ensure_client_node_for_model_dial_only( - &state, - request.peer_endpoint_addr, - ) - .await - { - last_error = format!("dial after connect-request failed: {error}"); - } else { - return Ok(attempt_id); - } - } - Err(error) => last_error = error, - } - tokio::time::sleep(RETRY_BACKOFF).await; - } - Err(format!( - "mesh connect attempt {attempt_id} exhausted its window: {last_error}" - )) -} - -/// Build + sign + submit the kind:24621 connect-request as the Buzz identity. -async fn publish_connect_request( - state: &AppState, - request: &RelayMeshConnectRequest<'_>, - attempt_id: &str, -) -> Result<(), String> { - let builder = build_connect_request_event(request, attempt_id)?; - crate::relay::submit_event(builder, state).await.map(|_| ()) -} - -fn build_connect_request_event( - request: &RelayMeshConnectRequest<'_>, - attempt_id: &str, +pub(crate) fn build_status_report_event( + payload: serde_json::Value, ) -> Result { - let mut content = json!({ - "v": 1, - "self_endpoint_addr": request.self_endpoint_addr, - "peer_endpoint_addr": request.peer_endpoint_addr, - "attempt_id": attempt_id, - }); - if let Some(endpoint_id) = request.self_endpoint_id { - content["self_endpoint_id"] = serde_json::Value::String(endpoint_id.to_string()); - } - if let Some(endpoint_id) = request.peer_endpoint_id { - content["peer_endpoint_id"] = serde_json::Value::String(endpoint_id.to_string()); - } - let target = nostr::PublicKey::from_hex(request.target_pubkey) - .map_err(|e| format!("invalid target pubkey: {e}"))?; + let owner_id = payload + .get("ownerId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "mesh discovery status is missing ownerId".to_string())?; + let d_tag = format!("{STATUS_D_TAG_PREFIX}:{owner_id}"); + let d = Tag::parse(["d", d_tag.as_str()]).map_err(|error| error.to_string())?; + let k = Tag::parse(["k", "buzz-mesh-status"]).map_err(|error| error.to_string())?; Ok(nostr::EventBuilder::new( - nostr::Kind::Custom(KIND_MESH_CONNECT_REQUEST as u16), - content.to_string(), - ) - .tag(nostr::Tag::public_key(target))) -} - -pub(crate) fn build_status_report_event(payload: serde_json::Value) -> nostr::EventBuilder { - nostr::EventBuilder::new( - nostr::Kind::Custom(KIND_MESH_STATUS_REPORT as u16), + nostr::Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS), payload.to_string(), ) + .tags([d, k])) } pub(crate) async fn publish_status_report( state: &AppState, payload: serde_json::Value, ) -> Result<(), String> { - crate::relay::submit_event(build_status_report_event(payload), state) + crate::relay::submit_event(build_status_report_event(payload)?, state) .await .map(|_| ()) } -/// Extract the peer endpoint addr from a paired call-me-now (24622) event, -/// dropping expired ones. -fn call_me_now_peer_addr(event: &nostr::Event) -> Option { - let payload: serde_json::Value = serde_json::from_str(&event.content).ok()?; - if payload.get("type")?.as_str()? != "buzz-iroh-call-me-now" { - return None; - } - let now = chrono::Utc::now().timestamp().max(0) as u64; - if let Some(expires_at) = payload.get("expires_at").and_then(|v| v.as_u64()) { - if expires_at < now { - return None; - } - } - payload - .get("peer_endpoint_addr")? - .as_str() - .map(str::to_string) -} - -/// NIP-42 AUTH as the Buzz identity. Generalized from `commands::pairing`'s -/// `handle_nip42_auth` — same flow, signs with `state.keys` instead of a -/// pairing session. Returns `Ok(())` when the relay does not challenge. -async fn authenticate( - app: &AppHandle, - relay_url: &str, - read: &mut R, - write: &mut W, -) -> Result<(), String> -where - R: StreamExt> + Unpin, - W: SinkExt + Unpin, -{ - let challenge = match tokio::time::timeout(Duration::from_secs(3), async { - loop { - let msg = read - .next() - .await - .ok_or_else(|| "relay closed during mesh auth".to_string())? - .map_err(|e| format!("WS error during mesh auth: {e}"))?; - if let Message::Text(text) = msg { - if let Some(challenge) = parse_auth_challenge(text.as_str()) { - return Ok::(challenge); - } - } - } - }) - .await - { - Ok(Ok(c)) => c, - Ok(Err(e)) => return Err(e), - Err(_) => return Ok(()), // no challenge: relay does not require AUTH - }; - - let relay_url_parsed = - nostr::RelayUrl::parse(relay_url).map_err(|e| format!("invalid relay URL: {e}"))?; - // Sign synchronously, drop the guard before awaiting (keep the future Send). - let auth_json = { - let state = app.state::(); - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let event = nostr::EventBuilder::auth(challenge, relay_url_parsed) - .sign_with_keys(&keys) - .map_err(|e| format!("sign mesh auth event: {e}"))?; - format!("[\"AUTH\",{}]", event.as_json()) - }; - write - .send(Message::Text(auth_json.into())) - .await - .map_err(|e| format!("send mesh auth: {e}"))?; - Ok(()) -} - -fn parse_auth_challenge(text: &str) -> Option { - let arr: serde_json::Value = serde_json::from_str(text).ok()?; - let arr = arr.as_array()?; - if arr.len() >= 2 && arr[0].as_str()? == "AUTH" { - return arr[1].as_str().map(str::to_string); - } - None -} - -fn parse_relay_event(text: &str, sub_id: &str) -> Option { - let arr: serde_json::Value = serde_json::from_str(text).ok()?; - let arr = arr.as_array()?; - if arr.len() < 3 || arr[0].as_str()? != "EVENT" || arr[1].as_str()? != sub_id { - return None; - } - serde_json::from_value(arr[2].clone()).ok() -} - #[cfg(test)] mod tests { + use nostr::JsonUtil; + use super::*; #[test] - fn connect_request_event_includes_optional_endpoint_ids() { - let keys = nostr::Keys::generate(); - let request = RelayMeshConnectRequest { - target_pubkey: &keys.public_key().to_hex(), - peer_endpoint_addr: "peer-addr", - self_endpoint_addr: "self-addr", - peer_endpoint_id: Some("peer-id"), - self_endpoint_id: Some("self-id"), - }; - let event = build_connect_request_event(&request, "attempt-1") - .expect("build event") - .sign_with_keys(&keys) - .expect("sign event"); - let content: serde_json::Value = serde_json::from_str(&event.content).unwrap(); - assert_eq!(content["self_endpoint_id"], "self-id"); - assert_eq!(content["peer_endpoint_id"], "peer-id"); - } - - #[test] - fn connect_request_event_omits_absent_endpoint_ids() { - let keys = nostr::Keys::generate(); - let request = RelayMeshConnectRequest { - target_pubkey: &keys.public_key().to_hex(), - peer_endpoint_addr: "peer-addr", - self_endpoint_addr: "self-addr", - peer_endpoint_id: None, - self_endpoint_id: None, - }; - let event = build_connect_request_event(&request, "attempt-1") - .expect("build event") - .sign_with_keys(&keys) - .expect("sign event"); - let content: serde_json::Value = serde_json::from_str(&event.content).unwrap(); - assert!(content.get("self_endpoint_id").is_none()); - assert!(content.get("peer_endpoint_id").is_none()); - } - - #[test] - fn status_report_event_uses_kind_and_exact_content() { - let keys = nostr::Keys::generate(); - let payload = json!({"v": 1, "models": ["demo"]}); - let event = build_status_report_event(payload.clone()) - .sign_with_keys(&keys) - .expect("sign event"); - assert_eq!( - event.kind, - nostr::Kind::Custom(KIND_MESH_STATUS_REPORT as u16) - ); - assert_eq!( - serde_json::from_str::(&event.content).unwrap(), - payload + fn heartbeat_leaves_room_before_status_expires() { + assert!( + STATUS_PUBLISH_INTERVAL.as_secs() * 2 < super::super::discovery::STATUS_FRESHNESS_SECS ); } #[test] - fn stopped_status_payload_withdraws_all_targets() { + fn stopped_status_advertises_identity_without_targets() { + let identity = super::super::identity::OwnerIdentity { + keystore_path: "/tmp/unused".into(), + owner_id: "owner-test".into(), + verifying_key_hex: "verify-test".into(), + }; assert_eq!( - stopped_status_payload(), - json!({ - "token": "", - "hosted_models": [], - "serving_models": [], - "peers": [], + stopped_status_payload(&identity), + serde_json::json!({ + "ownerId": "owner-test", + "ownerVerifyingKey": "verify-test", + "serveTargets": [], + "models": [], }) ); } #[test] - fn call_me_now_peer_addr_extracts_unexpired() { - let future = chrono::Utc::now().timestamp() as u64 + 30; - let event = test_event(&json!({ - "type": "buzz-iroh-call-me-now", - "peer_endpoint_addr": "node-abc", - "attempt_id": "a1", - "expires_at": future, - })); - assert_eq!(call_me_now_peer_addr(&event).as_deref(), Some("node-abc")); - } - - #[test] - fn call_me_now_peer_addr_drops_expired() { - let event = test_event(&json!({ - "type": "buzz-iroh-call-me-now", - "peer_endpoint_addr": "node-abc", - "attempt_id": "a1", - "expires_at": 1u64, - })); - assert_eq!(call_me_now_peer_addr(&event), None); - } - - #[test] - fn call_me_now_peer_addr_rejects_wrong_type() { - let event = test_event(&json!({ - "type": "something-else", - "peer_endpoint_addr": "node-abc", - })); - assert_eq!(call_me_now_peer_addr(&event), None); - } - - #[test] - fn parse_auth_challenge_reads_nip42() { - assert_eq!( - parse_auth_challenge(r#"["AUTH","chal-123"]"#).as_deref(), - Some("chal-123") - ); - assert_eq!(parse_auth_challenge(r#"["EVENT","x"]"#), None); - } - - fn test_event(content: &serde_json::Value) -> nostr::Event { + fn status_is_an_ordinary_client_replaceable_event() { let keys = nostr::Keys::generate(); - nostr::EventBuilder::new( - nostr::Kind::Custom(KIND_MESH_CALL_ME_NOW as u16), - content.to_string(), - ) - .sign_with_keys(&keys) - .expect("sign test event") + let event = build_status_report_event(serde_json::json!({"ownerId":"owner"})) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + assert_eq!( + event.kind, + nostr::Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS) + ); + assert_eq!(event.pubkey, keys.public_key()); + assert!(event + .as_json() + .contains(&format!("{STATUS_D_TAG_PREFIX}:owner"))); } } diff --git a/desktop/src-tauri/src/mesh_llm/discovery.rs b/desktop/src-tauri/src/mesh_llm/discovery.rs index ad12a1bf51..3907a2731e 100644 --- a/desktop/src-tauri/src/mesh_llm/discovery.rs +++ b/desktop/src-tauri/src/mesh_llm/discovery.rs @@ -1,37 +1,183 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use sha2::{Digest, Sha256}; use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, MESH_STATUS_KIND}; +/// Running-node status notes are refreshed every 45 seconds. Ignore notes older +/// than two minutes so crashed/offline devices stop contributing compute or +/// admission identities without requiring a relay-side cleanup job. +pub(super) const STATUS_FRESHNESS_SECS: u64 = 120; + +fn status_is_fresh(event: &nostr::Event, now: u64) -> bool { + event + .created_at + .as_secs() + .saturating_add(STATUS_FRESHNESS_SECS) + >= now +} + fn dedupe_targets(targets: Vec) -> Vec { - let mut by_endpoint = BTreeMap::::new(); + let mut by_endpoint_and_model = BTreeMap::<(String, String), MeshServeTarget>::new(); for target in targets { - by_endpoint - .entry(target.endpoint_addr.clone()) + by_endpoint_and_model + .entry((target.endpoint_addr.clone(), target.model_id.clone())) .or_insert(target); } - by_endpoint.into_values().collect() + by_endpoint_and_model.into_values().collect() +} + +/// Resolve the mesh admission roster from relay status and membership events. +/// +/// Only status notes signed by a currently listed NIP-43 direct member +/// contribute an owner id. This removes stale notes from former members and +/// ignores notes from nonmembers. If the relay has no membership snapshot, the +/// roster is empty and MeshLLM admission therefore remains self-only. +pub fn owner_ids_from_events(events: &[nostr::Event]) -> Vec { + let Some(members) = latest_membership_list(events) else { + return Vec::new(); + }; + let now = nostr::Timestamp::now().as_secs(); + let mut ids: Vec = events + .iter() + .filter(|event| { + event.kind.as_u16() as u64 == MESH_STATUS_KIND && status_is_fresh(event, now) + }) + .filter(|event| { + reporter_pubkey_from_status_event(event) + .is_some_and(|reporter| members.contains(&reporter.to_ascii_lowercase())) + }) + .filter_map(owner_id_from_status_event) + .collect(); + ids.sort(); + ids.dedup(); + ids +} + +fn latest_membership_list(events: &[nostr::Event]) -> Option> { + events + .iter() + .filter(|event| event.kind.as_u16() == 13_534) + .max_by_key(|event| event.created_at) + .map(|event| { + event + .tags + .iter() + .filter_map(|tag| { + let slice = tag.as_slice(); + let name = slice.first()?; + if name != "member" && name != "p" { + return None; + } + slice + .get(1) + .map(|pubkey| pubkey.trim().to_ascii_lowercase()) + }) + .filter(|pubkey| !pubkey.is_empty()) + .collect() + }) +} + +fn owner_id_from_status_event(event: &nostr::Event) -> Option { + let content = serde_json::from_str::(&event.content).ok()?; + let owner_id = content + .get("ownerId") + .or_else(|| content.get("owner_id"))? + .as_str()? + .trim(); + let verifying_key_bytes: [u8; 32] = + hex::decode(content.get("ownerVerifyingKey")?.as_str()?.trim()) + .ok()? + .try_into() + .ok()?; + let derived_owner = hex::encode(Sha256::digest(verifying_key_bytes)); + if owner_id != derived_owner { + return None; + } + let signature_bytes = hex::decode(content.get("ownerBindingSig")?.as_str()?.trim()).ok()?; + let signature = Signature::from_slice(&signature_bytes).ok()?; + let verifying_key = VerifyingKey::from_bytes(&verifying_key_bytes).ok()?; + verifying_key + .verify( + &super::identity::member_binding_bytes(&event.pubkey.to_hex()), + &signature, + ) + .ok()?; + Some(owner_id.to_string()) +} + +fn endpoint_binding_is_valid(event: &nostr::Event, content: &serde_json::Value) -> bool { + let Some(endpoint_tokens) = super::identity::advertised_endpoint_tokens(content) else { + return false; + }; + let Some(verifying_key_bytes) = content + .get("ownerVerifyingKey") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .and_then(|value| hex::decode(value).ok()) + .and_then(|value| <[u8; 32]>::try_from(value).ok()) + else { + return false; + }; + let Some(signature) = content + .get("ownerEndpointBindingSig") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .and_then(|value| hex::decode(value).ok()) + .and_then(|value| Signature::from_slice(&value).ok()) + else { + return false; + }; + let Ok(verifying_key) = VerifyingKey::from_bytes(&verifying_key_bytes) else { + return false; + }; + verifying_key + .verify( + &super::identity::member_endpoint_binding_bytes( + &event.pubkey.to_hex(), + &endpoint_tokens, + ), + &signature, + ) + .is_ok() } pub fn availability_from_events(events: Vec) -> MeshAvailability { if events.is_empty() { - return MeshAvailability::unavailable("relay mesh status is not published yet"); + return MeshAvailability::unavailable("Buzz shared compute status is not published yet"); } + let Some(members) = latest_membership_list(&events) else { + return MeshAvailability::unavailable( + "Buzz shared compute is waiting for the current member roster", + ); + }; - // Relay status is now per reporter (d=buzz-relay-mesh:), so a - // query returns multiple replaceable events. Aggregate them; do not pick the - // newest single event or one member's machines hide everyone else's. + // Status is replaceable per member pubkey, so a query returns multiple + // events. Aggregate only current members: a removed member's last status + // must not remain selectable after their admission is revoked. let mut all_targets = Vec::::new(); let mut all_models = Vec::::new(); let mut saw_valid_status = false; + let now = nostr::Timestamp::now().as_secs(); for event in events { + if event.kind.as_u16() as u64 != MESH_STATUS_KIND + || !status_is_fresh(&event, now) + || !members.contains(&event.pubkey.to_hex().to_ascii_lowercase()) + { + continue; + } let Ok(content) = serde_json::from_str::(&event.content) else { continue; }; + if owner_id_from_status_event(&event).is_none() { + continue; + } + if !endpoint_binding_is_valid(&event, &content) { + continue; + } saw_valid_status = true; - let reporter_pubkey = reporter_pubkey_from_status_event(&event); let mut serve_targets = content .get("serveTargets") .or_else(|| content.get("serve_targets")) @@ -39,12 +185,12 @@ pub fn availability_from_events(events: Vec) -> MeshAvailability { .and_then(|value| serde_json::from_value::>(value).ok()) .unwrap_or_default() .into_iter() - .map(|mut target| { - if target.reporter_pubkey.is_none() { - target.reporter_pubkey = reporter_pubkey.clone(); - } + .filter_map(|mut target| { + let endpoint_id = + super::transport_policy::validate_advertised_endpoint(&target.endpoint_addr) + .ok()?; if target.endpoint_id.is_none() { - target.endpoint_id = endpoint_id_from_invite_token(&target.endpoint_addr); + target.endpoint_id = Some(endpoint_id); } if target.device_id.is_none() { target.device_id = target.endpoint_id.clone(); @@ -55,7 +201,7 @@ pub fn availability_from_events(events: Vec) -> MeshAvailability { .clone() .or_else(|| target.endpoint_id.as_deref().map(short_endpoint_label)); } - target + Some(target) }) .collect::>(); @@ -79,20 +225,17 @@ pub fn availability_from_events(events: Vec) -> MeshAvailability { } if !saw_valid_status { - return MeshAvailability::unavailable("relay mesh status is malformed"); + return MeshAvailability::unavailable("Buzz shared compute status is malformed"); } let serve_targets = dedupe_targets(all_targets); let models = dedupe_models(all_models); let available = !serve_targets.is_empty(); MeshAvailability { - capable: true, - admitted: true, - available, reason: if available { None } else { - Some("no relay mesh serve targets are available".to_string()) + Some("no Buzz shared compute serving members are available".to_string()) }, models, serve_targets, @@ -107,18 +250,19 @@ pub fn mesh_status_filter() -> serde_json::Value { }) } -fn reporter_pubkey_from_status_event(event: &nostr::Event) -> Option { - event.tags.iter().find_map(|tag| { - let slice = tag.as_slice(); - let d = slice.get(1)?; - if slice.first().is_some_and(|name| name == "d") { - d.strip_prefix("buzz-relay-mesh:").map(ToString::to_string) - } else { - None - } +pub fn relay_membership_filter() -> serde_json::Value { + serde_json::json!({ + "kinds": [13534], + "limit": 1 }) } +fn reporter_pubkey_from_status_event(event: &nostr::Event) -> Option { + // Discovery notes are signed by the member that owns the MeshLLM identity. + // The generic relay only stores/queries them; it is not an identity oracle. + Some(event.pubkey.to_hex()) +} + pub(super) fn enrich_status_payload_identity( payload: &mut serde_json::Value, invite_token: Option<&str>, @@ -156,9 +300,7 @@ pub(super) fn device_name_from_status( } fn endpoint_id_from_invite_token(invite_token: &str) -> Option { - let json = URL_SAFE_NO_PAD.decode(invite_token).ok()?; - let value = serde_json::from_slice::(&json).ok()?; - string_value(&value, "id") + super::transport_policy::validate_advertised_endpoint(invite_token).ok() } fn string_value(value: &serde_json::Value, key: &str) -> Option { diff --git a/desktop/src-tauri/src/mesh_llm/identity.rs b/desktop/src-tauri/src/mesh_llm/identity.rs new file mode 100644 index 0000000000..e46bceab20 --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/identity.rs @@ -0,0 +1,149 @@ +//! Mesh owner identity for admission. +//! +//! Each machine gets a mesh-llm owner keypair (ed25519, distinct from the +//! Buzz/Nostr identity). The node presents a signed ownership attestation +//! binding `owner_id -> endpoint_id`, and serve nodes enforce an allowlist of +//! member owner ids (see `DesktopMeshRuntime::start`). The keystore lives at +//! mesh-llm's default path (`~/.mesh-llm/owner-keystore.json`) so a machine +//! has one owner identity whether mesh runs embedded in Buzz or standalone. + +use std::path::PathBuf; +use std::sync::OnceLock; + +use mesh_llm_host_runtime::crypto::{ + default_keystore_path, keystore_exists, load_keystore, save_keystore, OwnerKeypair, +}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone)] +pub struct OwnerIdentity { + pub keystore_path: PathBuf, + pub owner_id: String, + pub verifying_key_hex: String, +} + +impl OwnerIdentity { + /// Sign a Buzz-to-MeshLLM ownership binding. The member's Nostr signature + /// authenticates the discovery event; this Ed25519 signature proves the + /// advertised owner id is backed by the MeshLLM owner key itself. + pub fn sign_member_binding(&self, member_pubkey: &str) -> anyhow::Result { + let keypair = load_keystore(&self.keystore_path, None).map_err(|error| { + anyhow::anyhow!("failed to load mesh owner keystore for binding: {error}") + })?; + Ok(hex::encode( + keypair.sign_bytes(&member_binding_bytes(member_pubkey)), + )) + } + + /// Sign the exact endpoint tokens advertised by this member. This prevents + /// a holder of only the Nostr member key from reusing a valid owner binding + /// while substituting an attacker-selected dial target. + pub fn sign_member_endpoint_binding( + &self, + member_pubkey: &str, + endpoint_tokens: &[String], + ) -> anyhow::Result { + let keypair = load_keystore(&self.keystore_path, None).map_err(|error| { + anyhow::anyhow!("failed to load mesh owner keystore for endpoint binding: {error}") + })?; + Ok(hex::encode(keypair.sign_bytes( + &member_endpoint_binding_bytes(member_pubkey, endpoint_tokens), + ))) + } +} + +pub fn member_binding_bytes(member_pubkey: &str) -> Vec { + format!( + "buzz-mesh-owner-binding-v1:{}", + member_pubkey.trim().to_ascii_lowercase() + ) + .into_bytes() +} + +/// Canonical bytes binding a member-associated node identity to the exact set +/// of endpoint tokens in its status event. +pub fn member_endpoint_binding_bytes(member_pubkey: &str, endpoint_tokens: &[String]) -> Vec { + let mut endpoints = endpoint_tokens + .iter() + .map(|token| token.trim()) + .filter(|token| !token.is_empty()) + .collect::>(); + endpoints.sort_unstable(); + endpoints.dedup(); + + let mut digest = Sha256::new(); + for endpoint in endpoints { + digest.update((endpoint.len() as u64).to_be_bytes()); + digest.update(endpoint.as_bytes()); + } + format!( + "buzz-mesh-owner-endpoint-binding-v1:{}:{}", + member_pubkey.trim().to_ascii_lowercase(), + hex::encode(digest.finalize()) + ) + .into_bytes() +} + +/// Extract endpoint tokens from a status payload using the same canonical +/// field rules for publication and verification. +pub fn advertised_endpoint_tokens(payload: &serde_json::Value) -> Option> { + let Some(targets) = payload + .get("serveTargets") + .or_else(|| payload.get("serve_targets")) + else { + return Some(Vec::new()); + }; + let targets = targets.as_array()?; + targets + .iter() + .map(|target| { + target + .get("endpointAddr") + .or_else(|| target.get("endpoint_addr"))? + .as_str() + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(ToString::to_string) + }) + .collect() +} + +fn owner_identity(path: PathBuf, keypair: &OwnerKeypair) -> OwnerIdentity { + OwnerIdentity { + owner_id: keypair.owner_id(), + verifying_key_hex: hex::encode(keypair.verifying_key().as_bytes()), + keystore_path: path, + } +} + +/// Load-or-generate the machine's mesh owner identity. Cached for the process +/// lifetime — the keystore is stable once created. +pub fn ensure_owner_identity() -> anyhow::Result { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| ensure_owner_identity_uncached().map_err(|error| format!("{error:#}"))) + .clone() + .map_err(|error| anyhow::anyhow!(error)) +} + +fn ensure_owner_identity_uncached() -> anyhow::Result { + let path = default_keystore_path() + .map_err(|error| anyhow::anyhow!("cannot resolve mesh owner keystore path: {error}"))?; + if keystore_exists(&path) { + let keypair = load_keystore(&path, None).map_err(|error| { + anyhow::anyhow!( + "failed to load mesh owner keystore at {}: {error}", + path.display() + ) + })?; + return Ok(owner_identity(path, &keypair)); + } + let keypair = OwnerKeypair::generate(); + save_keystore(&path, &keypair, None, false).map_err(|error| { + anyhow::anyhow!( + "failed to save mesh owner keystore at {}: {error}", + path.display() + ) + })?; + Ok(owner_identity(path, &keypair)) +} diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index 3b53b9f501..a75fba1f16 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -1,34 +1,55 @@ use std::collections::BTreeMap; mod coordinator; -pub(crate) use coordinator::{ - publish_current_status_once, publish_stopped_status_once, RelayMeshConnectRequest, -}; -pub use coordinator::{spawn_listener, start_client, MeshCoordinator}; +pub(crate) use coordinator::{publish_current_status_once, publish_stopped_status_once}; +pub use coordinator::{start_coordinator, MeshCoordinator, KIND_BUZZ_MESH_MEMBER_STATUS}; mod discovery; -pub use discovery::{availability_from_events, mesh_status_filter}; +pub use discovery::{ + availability_from_events, mesh_status_filter, owner_ids_from_events, relay_membership_filter, +}; use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity}; -mod preset; -pub use preset::{agent_preset, MeshAgentPreset, MeshAgentPresetRequest}; +mod catalog; +pub use catalog::{model_catalog, MeshModelCatalog}; -use mesh_llm_sdk::{client, serve, EmbeddedNodeHandle, MeshDiscoveryMode}; +mod identity; +pub use identity::ensure_owner_identity; + +mod progress; +pub use progress::install_progress_sink; + +mod transport_policy; +#[cfg(test)] +use transport_policy::iroh_relay_mode_from; +use transport_policy::{iroh_relay_mode, validate_advertised_endpoint, IrohRelayMode}; + +use mesh_llm_sdk::{client, serve, EmbeddedNodeHandle, MeshDiscoveryMode, TrustPolicy}; use serde::{Deserialize, Serialize}; +use std::time::Duration; const DEFAULT_MESH_API_PORT: u16 = 9337; const DEFAULT_MESH_CONSOLE_PORT: u16 = 3131; -const MESH_STATUS_KIND: u64 = 30_621; +const MESH_STATUS_KIND: u64 = KIND_BUZZ_MESH_MEMBER_STATUS as u64; const MESH_API_PORT_ENV: &str = "BUZZ_MESH_API_PORT"; const MESH_CONSOLE_PORT_ENV: &str = "BUZZ_MESH_CONSOLE_PORT"; -const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; -/// ACP provider relay-mesh agents run on. Sources of truth for its command + -/// MCP live in the runtime catalog (`known_acp_runtime_exact`); these are -/// only the fallbacks. `buzz-agent` reads the `BUZZ_AGENT_PROVIDER` / -/// `OPENAI_COMPAT_*` env vars below — goose (the global default) does not. -const MESH_AGENT_PROVIDER_ID: &str = "buzz-agent"; -const MESH_AGENT_MCP_COMMAND: &str = "buzz-dev-mcp"; - +/// Iroh relay tunneling for symmetric-NAT peers. Unset/empty/"1"/"default" = +/// enabled with the SDK's default iroh relays (the default — members connect +/// regardless of NAT). "0" = disabled (direct QUIC only, for +/// metadata-conscious deployments). Any other value = comma-separated custom +/// iroh relay URLs. Relays forward end-to-end encrypted QUIC (ciphertext +/// only) and are transport-only; mesh presence is NEVER published to public +/// Nostr relays regardless of this setting (`publish` is hardcoded false and +/// the Nostr relay list stays empty). +const MESH_IROH_RELAYS_ENV: &str = "BUZZ_MESH_IROH_RELAYS"; +/// First model load can include a multi-GB download plus Metal warmup; the +/// SDK default (30s) times out long before that. Matches mesh-console. +const MESH_STARTUP_TIMEOUT: Duration = Duration::from_secs(180); +/// Sentinel model id meaning "let the mesh router pick". mesh-llm's OpenAI +/// ingress auto-routes `"model": "auto"` to a context-compatible live target +/// (`resolve_auto_routed_model`), so agents don't have to name a model and +/// can't pick one that doesn't fit their prompt. +pub const AUTO_MODEL_ID: &str = "auto"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct MeshModelOption { @@ -45,8 +66,6 @@ pub struct MeshServeTarget { pub node_name: Option, pub capacity: Option, #[serde(default)] - pub reporter_pubkey: Option, - #[serde(default)] pub endpoint_id: Option, #[serde(default)] pub device_id: Option, @@ -102,9 +121,6 @@ impl MeshHealth { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct MeshAvailability { - pub capable: bool, - pub admitted: bool, - pub available: bool, pub reason: Option, pub models: Vec, pub serve_targets: Vec, @@ -113,9 +129,6 @@ pub struct MeshAvailability { impl MeshAvailability { pub fn unavailable(reason: impl Into) -> Self { Self { - capable: false, - admitted: false, - available: false, reason: Some(reason.into()), models: Vec::new(), serve_targets: Vec::new(), @@ -150,14 +163,14 @@ pub struct StartMeshNodeRequest { pub max_vram_gb: Option, #[serde(default)] pub join_token: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct EnsureMeshClientRequest { - pub model_id: String, + /// Mesh owner ids admitted to this node (the member roster from + /// member-signed discovery notes). `None` = caller did not resolve a roster + /// (tests, direct invocations): the node runs without allowlist + /// enforcement, matching an open relay. `Some` = enforce + /// `TrustPolicy::Allowlist` over exactly these owners (self is always + /// included by the caller). #[serde(default)] - pub endpoint_addr: Option, + pub trusted_owner_ids: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -201,36 +214,77 @@ pub struct DesktopMeshRuntime { mode: MeshNodeMode, model_id: Option, model_name: Option, + /// The request this node was started with. Kept so the coordinator can + /// detect roster drift (membership changed → trusted owners changed) and + /// restart the node with the fresh roster — the SDK's trust store is + /// fixed at node start, so a restart is how roster changes take effect. + start_request: StartMeshNodeRequest, } -fn initialize_mesh_native_runtime() -> anyhow::Result<()> { - let cache = mesh_llm_sdk::native_runtime::native_runtime_cache(None)?; - let installed = cache.installed()?; - let current = mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION; - if !installed - .iter() - .any(|runtime| runtime.mesh_version == current) - { - anyhow::bail!( - "mesh native runtime for MeshLLM {current} is not installed; run `just mesh=1 staging` or `just mesh-e2e-hardware` to prepare it" - ); - } - mesh_llm_host_runtime::initialize_host_runtime().map_err(|error| { - anyhow::anyhow!( - "mesh native runtime failed to load; run `just mesh=1 staging` or `just mesh-e2e-hardware` to repair it: {error}" - ) +async fn initialize_mesh_native_runtime() -> anyhow::Result<()> { + // The dynamic host runtime installs the recommended signed runtime on first + // use when no compatible version is cached. Keep that SDK-owned path intact + // so release builds work on clean machines without bundling llama.cpp or + // requiring a separate `mesh-llm runtime install` command. + mesh_llm_host_runtime::initialize_host_runtime() + .await + .map_err(|error| { + anyhow::anyhow!("mesh native runtime failed to install or load: {error:#}") + }) +} + +/// Tokio worker stack size for the runtime that polls mesh-llm futures. +/// +/// mesh-llm's async call chains (model download, node start/join) are deep +/// enough to overflow tokio's default 2 MiB worker stacks — observed as a +/// stack-guard SIGABRT inside `download_model_ref_with_progress_details` +/// when polled on Tauri's stock runtime. Upstream runs its own binary on +/// 8 MiB worker stacks for exactly this reason (mesh-llm `main.rs`, +/// `DEFAULT_WORKER_STACK_SIZE`), as does mesh-console. `lib.rs` installs a +/// runtime with this stack size via `tauri::async_runtime::set` before the +/// app starts, so every command future gets the same headroom. +pub const MESH_WORKER_STACK_SIZE: usize = 8 * 1024 * 1024; + +/// Pre-download the model (with byte progress through the output sink) +/// before the node starts. Without this the download happens *inside* +/// `serve::start()` where the UI can only show a frozen "starting…" state. +/// Already-installed models return immediately from the cache scan. +async fn ensure_model_downloaded(model: &str) -> anyhow::Result<()> { + let model_owned = model.to_string(); + let installed = tokio::task::spawn_blocking(move || { + let cache = mesh_llm_node::models::default_huggingface_cache_dir(); + mesh_llm_node::models::scan_installed_models(cache) + .iter() + .any(|m| m.model_ref.contains(&model_owned)) }) + .await + .unwrap_or(false); + if installed { + return Ok(()); + } + mesh_llm_host_runtime::models::download_model_ref_with_progress_details(model, true) + .await + .map(|_| ()) + .map_err(|error| anyhow::anyhow!("downloading {model} failed: {error}")) } impl DesktopMeshRuntime { pub async fn start(request: StartMeshNodeRequest) -> anyhow::Result { validate_no_leak_request(&request)?; - initialize_mesh_native_runtime()?; + initialize_mesh_native_runtime().await?; let model_id = request .model_id .clone() .filter(|value| !value.trim().is_empty()); let model_name = model_id.clone(); + // Serve mode downloads weights before the node starts so byte + // progress reaches the UI through the output sink; inside + // serve::start() the download is invisible. + if request.mode == MeshNodeMode::Serve { + if let Some(model) = model_id.as_deref() { + ensure_model_downloaded(model).await?; + } + } let handle = match request.mode { MeshNodeMode::Serve => { let model = model_id @@ -240,31 +294,72 @@ impl DesktopMeshRuntime { .model(model) .api_port(mesh_api_port()?) .console_port(mesh_console_port()?) + // No-leak invariants: never publish mesh presence, never + // auto-discover other meshes, no public Nostr relays. + // Iroh relays are transport-only and enabled by default + // (see MESH_IROH_RELAYS_ENV); everything else stays closed. .publish(false) .auto_join(false) - .disable_iroh_relays(true) .discovery_mode(MeshDiscoveryMode::Nostr) + .startup_timeout(MESH_STARTUP_TIMEOUT) .console_ui(true); + builder = match iroh_relay_mode()? { + IrohRelayMode::Disabled => builder.disable_iroh_relays(true), + IrohRelayMode::Default => builder.disable_iroh_relays(false), + IrohRelayMode::Custom(urls) => builder + .disable_iroh_relays(false) + .iroh_relays(urls.into_iter().map(|url| url.to_string())), + }; if let Some(max_vram_gb) = request.max_vram_gb { builder = builder.max_vram_gb(max_vram_gb as f64); } if let Some(join_token) = request.join_token.as_deref() { builder = builder.join_token(join_token); } + // Admission: present our owner attestation, and when a member + // roster was resolved, admit only those owners. Membership in + // the Buzz relay is the source of the roster; possession of a + // dial pointer or relay reachability admits nobody. + let identity = ensure_owner_identity()?; + builder = builder.owner_key(identity.keystore_path.clone()); + if let Some(owners) = normalized_roster(&request.trusted_owner_ids, &identity) { + builder = builder + .owner_required(true) + .trust_policy(TrustPolicy::Allowlist) + .trust_owners(owners); + } serve::start(builder.build()).await? } MeshNodeMode::Client => { let mut builder = client::EmbeddedClientConfig::builder() .api_port(mesh_api_port()?) .console_port(mesh_console_port()?) + // Same no-leak invariants as serve mode above. .publish(false) .auto_join(false) - .disable_iroh_relays(true) .discovery_mode(MeshDiscoveryMode::Nostr) + .startup_timeout(MESH_STARTUP_TIMEOUT) .console_ui(true); + builder = match iroh_relay_mode()? { + IrohRelayMode::Disabled => builder.disable_iroh_relays(true), + IrohRelayMode::Default => builder.disable_iroh_relays(false), + IrohRelayMode::Custom(urls) => builder + .disable_iroh_relays(false) + .iroh_relays(urls.into_iter().map(|url| url.to_string())), + }; if let Some(join_token) = request.join_token.as_deref() { builder = builder.join_token(join_token); } + // Clients always present their owner attestation so allowlist + // enforcing serve nodes can verify and admit them. + let identity = ensure_owner_identity()?; + builder = builder.owner_key(identity.keystore_path.clone()); + if let Some(owners) = normalized_roster(&request.trusted_owner_ids, &identity) { + builder = builder + .owner_required(true) + .trust_policy(TrustPolicy::Allowlist) + .trust_owners(owners); + } client::start(builder.build()).await? } }; @@ -274,9 +369,15 @@ impl DesktopMeshRuntime { mode: request.mode, model_id, model_name, + start_request: request, }) } + /// The request this node was started with (roster drift detection). + pub fn start_request(&self) -> &StartMeshNodeRequest { + &self.start_request + } + pub async fn status(&self) -> anyhow::Result { let status = self.handle.status().await?; self.status_from_sdk(status) @@ -286,10 +387,39 @@ impl DesktopMeshRuntime { let status = self.handle.status().await?; let mut payload = status.payload; enrich_status_payload_identity(&mut payload, status.invite_token.as_deref()); + if let Ok(identity) = ensure_owner_identity() { + payload["ownerId"] = serde_json::Value::String(identity.owner_id); + } + let models = models_from_status_payload(Some(&payload)); + payload["models"] = serde_json::to_value(&models)?; + let endpoint_addr = status.invite_token.unwrap_or_default(); + let serve_targets = if self.mode == MeshNodeMode::Serve && !endpoint_addr.is_empty() { + models + .into_iter() + .map(|model| MeshServeTarget { + model_id: model.id, + model_name: model.name, + endpoint_addr: endpoint_addr.clone(), + node_name: payload + .get("node_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + capacity: None, + endpoint_id: endpoint_id_from_status(&payload, Some(&endpoint_addr)), + device_id: None, + device_name: None, + }) + .collect::>() + } else { + Vec::new() + }; + payload["serveTargets"] = serde_json::to_value(serve_targets)?; Ok(payload) } pub async fn dial_endpoint_addr(&self, endpoint_addr: impl Into) -> anyhow::Result<()> { + let endpoint_addr = endpoint_addr.into(); + validate_advertised_endpoint(&endpoint_addr)?; self.handle.join_token(endpoint_addr).await } @@ -303,15 +433,12 @@ impl DesktopMeshRuntime { status: mesh_llm_sdk::EmbeddedNodeStatus, ) -> anyhow::Result { let health = health_from_payload(&status.payload); + let state = node_state_from_payload(self.mode, &health, &status.payload); let endpoint_id = endpoint_id_from_status(&status.payload, status.invite_token.as_deref()); let device_name = device_name_from_status(&status.payload, endpoint_id.as_deref()); let device_id = endpoint_id.clone(); Ok(MeshNodeStatus { - state: if matches!(health.status, MeshHealthStatus::Failed) { - MeshNodeState::Failed - } else { - MeshNodeState::Running - }, + state, mode: Some(self.mode), health, api_base_url: Some(status.api_base_url), @@ -355,14 +482,29 @@ fn mesh_port_from_env(name: &str, default: u16) -> anyhow::Result { Ok(port) } -fn relay_mesh_api_base_url() -> Result { - let port = mesh_api_port().map_err(|error| error.to_string())?; - Ok(format!("http://127.0.0.1:{port}/v1")) +/// Normalize a resolved roster for allowlist enforcement: sorted, deduped, +/// and always containing our own owner id (so a solo sharer can dial their +/// own node and the first member of a fresh relay isn't locked out). +/// `None` in = `None` out (no roster resolved → no enforcement). +fn normalized_roster( + trusted_owner_ids: &Option>, + identity: &identity::OwnerIdentity, +) -> Option> { + let ids = trusted_owner_ids.as_ref()?; + let mut owners: Vec = ids + .iter() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect(); + owners.push(identity.owner_id.clone()); + owners.sort(); + owners.dedup(); + Some(owners) } fn validate_no_leak_request(request: &StartMeshNodeRequest) -> anyhow::Result<()> { - if request.join_token.as_deref().is_some_and(str::is_empty) { - anyhow::bail!("joinToken cannot be empty when provided"); + if let Some(join_token) = request.join_token.as_deref() { + validate_advertised_endpoint(join_token)?; } Ok(()) } @@ -398,10 +540,43 @@ fn find_progressish_reason(value: &serde_json::Value) -> Option { None } +fn node_state_from_payload( + mode: MeshNodeMode, + health: &MeshHealth, + payload: &serde_json::Value, +) -> MeshNodeState { + if matches!(health.status, MeshHealthStatus::Failed) { + return MeshNodeState::Failed; + } + if mode == MeshNodeMode::Serve && models_from_status_payload(Some(payload)).is_empty() { + return MeshNodeState::Starting; + } + MeshNodeState::Running +} + pub fn models_from_status_payload(payload: Option<&serde_json::Value>) -> Vec { let mut out = Vec::new(); if let Some(payload) = payload { - collect_model_options(payload, &mut out); + // The SDK's raw status uses `hosted_models` plus ready entries under + // `runtime.models`. Buzz-authored status reports use `models`. Do not + // use `serving_models`: MeshLLM fills it with the requested model while + // the runtime is still in standby, before inference is available. + for key in ["models", "hosted_models"] { + if let Some(value) = payload.get(key) { + collect_model_options(value, &mut out); + } + } + if let Some(runtime_models) = payload + .get("runtime") + .and_then(|runtime| runtime.get("models")) + .and_then(serde_json::Value::as_array) + { + for model in runtime_models { + if model.get("status").and_then(serde_json::Value::as_str) == Some("ready") { + collect_model_options(model, &mut out); + } + } + } } dedupe_models(out) } @@ -415,18 +590,24 @@ fn collect_model_options(value: &serde_json::Value, out: &mut Vec { @@ -434,20 +615,13 @@ fn collect_model_options(value: &serde_json::Value, out: &mut Vec { + serde_json::Value::String(value) => { push_model(out, value, None); } _ => {} } } -fn looks_like_model_ref(value: &str) -> bool { - // Family-agnostic: a bare string is a ref only via URI scheme or .gguf ext. - let trimmed = value.trim(); - !trimmed.is_empty() - && (trimmed.starts_with("hf://") || trimmed.to_ascii_lowercase().ends_with(".gguf")) -} - fn push_model(out: &mut Vec, id: &str, name: Option) { let id = id.trim(); if id.is_empty() || id.starts_with("http://") || id.starts_with("https://") { diff --git a/desktop/src-tauri/src/mesh_llm/mod_tests.rs b/desktop/src-tauri/src/mesh_llm/mod_tests.rs index befcdb1aa5..49897ce65e 100644 --- a/desktop/src-tauri/src/mesh_llm/mod_tests.rs +++ b/desktop/src-tauri/src/mesh_llm/mod_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for `mesh_llm/mod.rs` private helpers (kept in a sibling file so //! `mod.rs` stays under the 500-line budget; `#[path]`-included from there). -use super::{find_progressish_reason, looks_like_model_ref}; +use super::find_progressish_reason; use serde_json::json; #[test] @@ -22,45 +22,497 @@ fn progressish_reads_typed_phase_not_whole_tree() { } #[test] -fn model_ref_is_family_agnostic() { - assert!(looks_like_model_ref("hf://org/model")); - assert!(looks_like_model_ref("some-model.gguf")); - assert!(looks_like_model_ref("Some-Model.GGUF")); - // Families that used to be hardcoded must route via the structured path, - // not a name allowlist here (Sami N2): - assert!(!looks_like_model_ref("Mistral-7B")); - assert!(!looks_like_model_ref("Qwen3-35B")); - assert!(!looks_like_model_ref("")); +fn sdk_ready_models_are_parsed_from_real_status_shape() { + let payload = json!({ + "node_state": "serving", + "llama_ready": true, + "hosted_models": ["Qwen/Qwen3-0.6B-GGUF:Q8_0"], + "serving_models": ["Qwen/Qwen3-0.6B-GGUF:Q8_0"], + "runtime": { + "models": [{ + "name": "Qwen/Qwen3-0.6B-GGUF:Q8_0", + "status": "ready" + }] + } + }); + + assert_eq!( + super::models_from_status_payload(Some(&payload)), + vec![super::MeshModelOption { + id: "Qwen/Qwen3-0.6B-GGUF:Q8_0".to_string(), + name: None, + }] + ); + assert_eq!( + super::node_state_from_payload( + super::MeshNodeMode::Serve, + &super::MeshHealth::ok(), + &payload, + ), + super::MeshNodeState::Running + ); } #[test] -fn agent_preset_runs_on_buzz_agent_not_goose() { - // Regression (Tyler): the relay-mesh preset used to hand the agent the - // global default runtime (goose), which ignores the OpenAI-compat env - // vars and falls back to its own provider. Mesh agents must run on - // buzz-agent, which reads those vars. - let preset = super::agent_preset(super::MeshAgentPresetRequest { - model_id: "Qwen3-8B-Q4_K_M".to_string(), - }) - .expect("preset for a valid model id"); +fn requested_model_is_not_ready_while_sdk_is_in_standby() { + let payload = json!({ + "node_state": "standby", + "llama_ready": false, + "hosted_models": [], + "serving_models": ["Qwen/Qwen3-0.6B-GGUF:Q8_0"], + "runtime": {"models": []} + }); - assert_eq!(preset.agent_command, "buzz-agent"); - assert_ne!(preset.agent_command, "goose"); - assert_eq!(preset.mcp_command, "buzz-dev-mcp"); - - // The env vars buzz-agent's config layer reads (crates/buzz-agent). + assert!(super::models_from_status_payload(Some(&payload)).is_empty()); assert_eq!( - preset - .env_vars - .get("BUZZ_AGENT_PROVIDER") - .map(String::as_str), - Some("openai") - ); - assert_eq!( - preset - .env_vars - .get("OPENAI_COMPAT_MODEL") - .map(String::as_str), - Some("Qwen3-8B-Q4_K_M") + super::node_state_from_payload( + super::MeshNodeMode::Serve, + &super::MeshHealth::ok(), + &payload, + ), + super::MeshNodeState::Starting ); } + +#[test] +fn iroh_relay_mode_defaults_to_enabled() { + // Default is ON: unset, empty, "1", and "default" all enable the SDK's + // default iroh relays, so members connect regardless of NAT. Relays are + // transport-only (ciphertext forwarding) — admission is a separate layer. + use super::IrohRelayMode; + assert_eq!( + super::iroh_relay_mode_from(None).unwrap(), + IrohRelayMode::Default + ); + assert_eq!( + super::iroh_relay_mode_from(Some("")).unwrap(), + IrohRelayMode::Default + ); + assert_eq!( + super::iroh_relay_mode_from(Some(" ")).unwrap(), + IrohRelayMode::Default + ); + assert_eq!( + super::iroh_relay_mode_from(Some("1")).unwrap(), + IrohRelayMode::Default + ); + assert_eq!( + super::iroh_relay_mode_from(Some("default")).unwrap(), + IrohRelayMode::Default + ); +} + +#[test] +fn iroh_relay_mode_opt_out_and_custom() { + use super::IrohRelayMode; + // "0" is the explicit opt-out for metadata-conscious deployments. + assert_eq!( + super::iroh_relay_mode_from(Some("0")).unwrap(), + IrohRelayMode::Disabled + ); + // Anything else is a comma-separated custom relay list. + assert_eq!( + super::iroh_relay_mode_from(Some("https://relay1.example, https://relay2.example ,")) + .unwrap(), + IrohRelayMode::Custom(vec![ + "https://relay1.example".parse().unwrap(), + "https://relay2.example".parse().unwrap(), + ]) + ); +} + +fn test_endpoint_token() -> String { + super::transport_policy::endpoint_token_for_test([iroh::TransportAddr::Ip( + "192.168.1.20:47916".parse().unwrap(), + )]) +} + +fn add_test_owner_bindings( + payload: &mut serde_json::Value, + owner: &mesh_llm_host_runtime::crypto::OwnerKeypair, + member_pubkey: &str, +) { + let endpoints = super::identity::advertised_endpoint_tokens(payload).unwrap(); + payload["ownerBindingSig"] = serde_json::Value::String(hex::encode( + owner.sign_bytes(&super::identity::member_binding_bytes(member_pubkey)), + )); + payload["ownerEndpointBindingSig"] = serde_json::Value::String(hex::encode(owner.sign_bytes( + &super::identity::member_endpoint_binding_bytes(member_pubkey, &endpoints), + ))); +} + +#[test] +fn normalized_roster_none_means_no_enforcement() { + let identity = super::identity::OwnerIdentity { + keystore_path: std::path::PathBuf::from("/tmp/ks.json"), + owner_id: "owner-self".to_string(), + verifying_key_hex: String::new(), + }; + assert_eq!(super::normalized_roster(&None, &identity), None); +} + +#[test] +fn normalized_roster_always_includes_self_and_dedupes() { + let identity = super::identity::OwnerIdentity { + keystore_path: std::path::PathBuf::from("/tmp/ks.json"), + owner_id: "owner-self".to_string(), + verifying_key_hex: String::new(), + }; + // Empty roster (fresh relay, nobody published yet) still admits self — + // otherwise the first sharer locks themselves out. + assert_eq!( + super::normalized_roster(&Some(vec![]), &identity), + Some(vec!["owner-self".to_string()]) + ); + // Dedup + trim + sorted, self merged in. + assert_eq!( + super::normalized_roster( + &Some(vec![ + "owner-b".to_string(), + " owner-a ".to_string(), + "owner-b".to_string(), + "".to_string(), + "owner-self".to_string(), + ]), + &identity + ), + Some(vec![ + "owner-a".to_string(), + "owner-b".to_string(), + "owner-self".to_string(), + ]) + ); +} + +fn signed_reporter_status(reporter_secret: &str, _label: &str) -> nostr::Event { + use mesh_llm_host_runtime::crypto::OwnerKeypair; + + let keys = nostr::Keys::parse(reporter_secret).expect("valid reporter secret"); + let owner = OwnerKeypair::generate(); + let member_pubkey = keys.public_key().to_hex(); + let mut payload = json!({ + "ownerId": owner.owner_id(), + "ownerVerifyingKey": hex::encode(owner.verifying_key().as_bytes()), + "serveTargets": [] + }); + add_test_owner_bindings(&mut payload, &owner, &member_pubkey); + super::coordinator::build_status_report_event(payload) + .expect("status builder") + .sign_with_keys(&keys) + .expect("test event signs") +} + +fn signed_membership_event(members: &[String]) -> nostr::Event { + let keys = nostr::Keys::generate(); + let tags = members + .iter() + .map(|member| nostr::Tag::parse(["member", member]).expect("valid member tag")) + .collect::>(); + nostr::EventBuilder::new(nostr::Kind::Custom(13_534), "") + .tags(tags) + .sign_with_keys(&keys) + .expect("test membership event signs") +} + +#[test] +fn owner_ids_from_events_collects_sorted_deduped_roster() { + let secret_a = "1".repeat(64); + let secret_b = "2".repeat(64); + let member_a = nostr::Keys::parse(&secret_a).unwrap().public_key().to_hex(); + let member_b = nostr::Keys::parse(&secret_b).unwrap().public_key().to_hex(); + let events = vec![ + signed_reporter_status(&secret_b, "owner-b"), + signed_reporter_status(&secret_a, "owner-a"), + signed_membership_event(&[member_a, member_b]), + ]; + let owners = super::owner_ids_from_events(&events); + assert_eq!(owners.len(), 2); + assert!(owners.windows(2).all(|pair| pair[0] < pair[1])); + assert_eq!(super::owner_ids_from_events(&[]), Vec::::new()); +} + +#[test] +fn owner_roster_intersects_status_reporters_with_current_members() { + let current_secret = "1".repeat(64); + let removed_secret = "2".repeat(64); + let nonmember_secret = "3".repeat(64); + let current_member = nostr::Keys::parse(¤t_secret) + .unwrap() + .public_key() + .to_hex(); + let events = vec![ + signed_reporter_status(¤t_secret, "owner-current"), + signed_reporter_status(&removed_secret, "owner-removed"), + signed_reporter_status(&nonmember_secret, "owner-nonmember"), + signed_membership_event(std::slice::from_ref(¤t_member)), + ]; + + assert_eq!(super::owner_ids_from_events(&events).len(), 1); +} + +#[test] +fn owner_roster_rejects_spoofed_owner_id_and_cross_member_binding() { + use mesh_llm_host_runtime::crypto::OwnerKeypair; + + let member_secret = "4".repeat(64); + let other_secret = "5".repeat(64); + let member_keys = nostr::Keys::parse(&member_secret).unwrap(); + let other_keys = nostr::Keys::parse(&other_secret).unwrap(); + let member_pubkey = member_keys.public_key().to_hex(); + let owner = OwnerKeypair::generate(); + let verifying_key = hex::encode(owner.verifying_key().as_bytes()); + let endpoint = test_endpoint_token(); + + let sign_status = |owner_id: String, binding_pubkey: &str| { + let mut payload = json!({ + "ownerId": owner_id, + "ownerVerifyingKey": verifying_key.clone(), + "serveTargets": [{ + "modelId": "spoofed-model", + "modelName": null, + "endpointAddr": endpoint.clone(), + "nodeName": null, + "capacity": null + }] + }); + add_test_owner_bindings(&mut payload, &owner, binding_pubkey); + super::coordinator::build_status_report_event(payload) + .unwrap() + .sign_with_keys(&member_keys) + .unwrap() + }; + + let spoofed_owner = sign_status("0".repeat(64), &member_pubkey); + let cross_member_binding = sign_status(owner.owner_id(), &other_keys.public_key().to_hex()); + let membership = signed_membership_event(std::slice::from_ref(&member_pubkey)); + + let events = vec![spoofed_owner, cross_member_binding, membership]; + assert!( + super::owner_ids_from_events(&events).is_empty(), + "a Buzz member must not be able to advertise an unproven MeshLLM owner identity" + ); + assert!( + super::availability_from_events(events) + .serve_targets + .is_empty(), + "an unproven owner identity must not contribute a selectable target" + ); +} + +#[test] +fn owner_roster_rejects_endpoint_substitution_without_owner_signature() { + use mesh_llm_host_runtime::crypto::OwnerKeypair; + + let keys = nostr::Keys::parse(&"b".repeat(64)).unwrap(); + let member_pubkey = keys.public_key().to_hex(); + let owner = OwnerKeypair::generate(); + let signed_endpoint = test_endpoint_token(); + let substituted_endpoint = test_endpoint_token(); + let mut payload = json!({ + "ownerId": owner.owner_id(), + "ownerVerifyingKey": hex::encode(owner.verifying_key().as_bytes()), + "serveTargets": [{ + "modelId": "model-a", + "modelName": null, + "endpointAddr": signed_endpoint, + "nodeName": null, + "capacity": null + }] + }); + add_test_owner_bindings(&mut payload, &owner, &member_pubkey); + payload["serveTargets"][0]["endpointAddr"] = serde_json::Value::String(substituted_endpoint); + let status = super::coordinator::build_status_report_event(payload) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + let membership = signed_membership_event(std::slice::from_ref(&member_pubkey)); + + assert_eq!( + super::owner_ids_from_events(&[status.clone(), membership.clone()]).len(), + 1, + "endpoint substitution must not erase a valid member-to-owner admission binding" + ); + assert!(super::availability_from_events(vec![status, membership]) + .serve_targets + .is_empty()); +} + +#[test] +fn availability_excludes_removed_member_status() { + let current_secret = "6".repeat(64); + let removed_secret = "7".repeat(64); + let current_member = nostr::Keys::parse(¤t_secret) + .unwrap() + .public_key() + .to_hex(); + let current_endpoint = test_endpoint_token(); + let events = vec![ + signed_reporter_target(¤t_secret, "model-current", ¤t_endpoint), + signed_reporter_target(&removed_secret, "model-removed", &test_endpoint_token()), + signed_membership_event(std::slice::from_ref(¤t_member)), + ]; + + let availability = super::availability_from_events(events); + assert_eq!(availability.models.len(), 1); + assert_eq!(availability.models[0].id, "model-current"); + assert_eq!(availability.serve_targets.len(), 1); + assert_eq!( + availability.serve_targets[0].endpoint_addr, + current_endpoint + ); +} + +fn signed_reporter_target(reporter_secret: &str, model: &str, endpoint: &str) -> nostr::Event { + use mesh_llm_host_runtime::crypto::OwnerKeypair; + + let keys = nostr::Keys::parse(reporter_secret).unwrap(); + let owner = OwnerKeypair::generate(); + let member_pubkey = keys.public_key().to_hex(); + let mut payload = json!({ + "ownerId": owner.owner_id(), + "ownerVerifyingKey": hex::encode(owner.verifying_key().as_bytes()), + "models": [{"id": model, "name": null}], + "serveTargets": [{ + "modelId": model, + "modelName": null, + "endpointAddr": endpoint, + "nodeName": null, + "capacity": null + }] + }); + add_test_owner_bindings(&mut payload, &owner, &member_pubkey); + super::coordinator::build_status_report_event(payload) + .unwrap() + .sign_with_keys(&keys) + .unwrap() +} + +#[test] +fn stale_status_is_excluded_from_admission_and_availability() { + let secret = "8".repeat(64); + let member = nostr::Keys::parse(&secret).unwrap().public_key().to_hex(); + let stale = signed_reporter_target_at(&secret, "stale-model", &test_endpoint_token(), 1_000); + // No newer event is required to age this status out. Freshness is measured + // against wall clock, so an entirely offline mesh cannot remain live forever. + let membership = signed_membership_event_at(std::slice::from_ref(&member), 900); + let events = vec![stale, membership]; + + assert!(super::owner_ids_from_events(&events).is_empty()); + let availability = super::availability_from_events(events); + assert!(availability.serve_targets.is_empty()); +} + +#[test] +fn one_endpoint_can_advertise_multiple_models() { + let secret = "a".repeat(64); + let member = nostr::Keys::parse(&secret).unwrap().public_key().to_hex(); + let endpoint = test_endpoint_token(); + let availability = super::availability_from_events(vec![ + signed_reporter_target(&secret, "model-a", &endpoint), + signed_reporter_target(&secret, "model-b", &endpoint), + signed_membership_event(std::slice::from_ref(&member)), + ]); + + assert_eq!(availability.serve_targets.len(), 2); + assert!(availability + .serve_targets + .iter() + .any(|target| target.model_id == "model-a")); + assert!(availability + .serve_targets + .iter() + .any(|target| target.model_id == "model-b")); +} + +#[test] +fn same_member_can_publish_multiple_owner_scoped_devices() { + let secret = "9".repeat(64); + let member = nostr::Keys::parse(&secret).unwrap().public_key().to_hex(); + let first = signed_reporter_target(&secret, "model-a", &test_endpoint_token()); + let second = signed_reporter_target(&secret, "model-b", &test_endpoint_token()); + let first_d = first + .tags + .iter() + .find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("d")) + .then(|| values.get(1).cloned()) + .flatten() + }) + .unwrap(); + let second_d = second + .tags + .iter() + .find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("d")) + .then(|| values.get(1).cloned()) + .flatten() + }) + .unwrap(); + assert_ne!( + first_d, second_d, + "device status coordinates must not overwrite" + ); + + let availability = super::availability_from_events(vec![ + first, + second, + signed_membership_event(std::slice::from_ref(&member)), + ]); + assert_eq!(availability.serve_targets.len(), 2); +} + +fn signed_reporter_target_at( + reporter_secret: &str, + model: &str, + endpoint: &str, + created_at: u64, +) -> nostr::Event { + use mesh_llm_host_runtime::crypto::OwnerKeypair; + + let keys = nostr::Keys::parse(reporter_secret).unwrap(); + let owner = OwnerKeypair::generate(); + let member_pubkey = keys.public_key().to_hex(); + let mut payload = json!({ + "ownerId": owner.owner_id(), + "ownerVerifyingKey": hex::encode(owner.verifying_key().as_bytes()), + "models": [{"id": model, "name": null}], + "serveTargets": [{ + "modelId": model, + "modelName": null, + "endpointAddr": endpoint, + "nodeName": null, + "capacity": null + }] + }); + add_test_owner_bindings(&mut payload, &owner, &member_pubkey); + super::coordinator::build_status_report_event(payload) + .unwrap() + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&keys) + .unwrap() +} + +fn signed_membership_event_at(members: &[String], created_at: u64) -> nostr::Event { + let keys = nostr::Keys::generate(); + let tags = members + .iter() + .map(|member| nostr::Tag::parse(["member", member]).unwrap()) + .collect::>(); + nostr::EventBuilder::new(nostr::Kind::Custom(13_534), "") + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&keys) + .unwrap() +} + +#[test] +fn owner_roster_without_membership_list_fails_closed() { + let events = vec![ + signed_reporter_status(&"1".repeat(64), "owner-a"), + signed_reporter_status(&"2".repeat(64), "owner-b"), + ]; + + assert!(super::owner_ids_from_events(&events).is_empty()); +} diff --git a/desktop/src-tauri/src/mesh_llm/preset.rs b/desktop/src-tauri/src/mesh_llm/preset.rs deleted file mode 100644 index 12e12c326f..0000000000 --- a/desktop/src-tauri/src/mesh_llm/preset.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Relay-mesh "Run on relay mesh" agent preset. Kept in a sibling file so -//! `mod.rs` stays under the 500-line budget; `#[path]`-included from there. -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; - -use super::{ - relay_mesh_api_base_url, MESH_AGENT_MCP_COMMAND, MESH_AGENT_PROVIDER_ID, - RELAY_MESH_API_KEY_PLACEHOLDER, -}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct MeshAgentPresetRequest { - pub model_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct MeshAgentPreset { - pub provider_id: String, - pub label: String, - pub acp_command: String, - pub agent_command: String, - pub agent_args: Vec, - pub mcp_command: String, - pub model: String, - pub env_vars: BTreeMap, -} - -pub fn agent_preset(request: MeshAgentPresetRequest) -> Result { - let model = request.model_id.trim(); - if model.is_empty() { - return Err("modelId is required".to_string()); - } - // Run on buzz-agent, not the global default (goose). Source command + - // MCP from the catalog so this can't drift from the provider definition. - let buzz_agent = crate::managed_agents::known_acp_runtime_exact(MESH_AGENT_PROVIDER_ID); - let agent_command = buzz_agent - .and_then(|p| p.commands.first().copied()) - .unwrap_or(MESH_AGENT_PROVIDER_ID) - .to_string(); - let mcp_command = buzz_agent - .and_then(|p| p.mcp_command) - .unwrap_or(MESH_AGENT_MCP_COMMAND) - .to_string(); - Ok(MeshAgentPreset { - provider_id: "relay-mesh".to_string(), - label: "Relay mesh".to_string(), - acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), - agent_command, - agent_args: Vec::new(), - mcp_command, - model: model.to_string(), - env_vars: BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - relay_mesh_api_base_url()?, - ), - ("OPENAI_COMPAT_MODEL".to_string(), model.to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ("OPENAI_COMPAT_API".to_string(), "chat".to_string()), - ]), - }) -} diff --git a/desktop/src-tauri/src/mesh_llm/progress.rs b/desktop/src-tauri/src/mesh_llm/progress.rs new file mode 100644 index 0000000000..a98b27c98f --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/progress.rs @@ -0,0 +1,73 @@ +//! Bridges mesh-llm's process-global output sink onto Tauri events so the +//! frontend can render model-download progress (bytes, percent) instead of a +//! frozen, greyed-out toggle. Same pattern as mesh-console's ConsoleSink. + +use std::io; +use std::sync::Arc; + +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +use mesh_llm_events::{ConsoleSessionMode, ModelProgressStatus, OutputEvent, OutputSink}; + +/// Tauri event name the frontend subscribes to. +pub const MESH_DOWNLOAD_PROGRESS_EVENT: &str = "mesh-download-progress"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MeshDownloadProgress { + /// Model (or runtime) label being downloaded. + pub label: String, + /// Specific file within the download, when known. + pub file: Option, + pub downloaded_bytes: Option, + pub total_bytes: Option, + /// preparing | downloading | done — mirrors ModelProgressStatus. + pub status: &'static str, + pub done: bool, +} + +struct TauriProgressSink { + app: AppHandle, +} + +impl OutputSink for TauriProgressSink { + fn emit_event(&self, event: OutputEvent) -> io::Result<()> { + if let OutputEvent::ModelDownloadProgress { + label, + file, + downloaded_bytes, + total_bytes, + status, + } = event + { + let payload = MeshDownloadProgress { + label, + file, + downloaded_bytes, + total_bytes, + status: match status { + ModelProgressStatus::Ensuring => "preparing", + ModelProgressStatus::Downloading => "downloading", + ModelProgressStatus::Ready => "done", + }, + done: matches!(status, ModelProgressStatus::Ready), + }; + let _ = self.app.emit(MESH_DOWNLOAD_PROGRESS_EVENT, payload); + } + Ok(()) + } + + /// Byte-level ModelDownloadProgress only flows through the sink when the + /// host-runtime believes an interactive dashboard is attached; otherwise + /// it draws ANSI progress bars on stderr. Our UI *is* the dashboard. + fn console_session_mode(&self) -> Option { + Some(ConsoleSessionMode::InteractiveDashboard) + } +} + +/// Install (or replace) the process-global progress sink pointed at this app. +/// Idempotent in effect — replacing with an equivalent sink is harmless. +pub fn install_progress_sink(app: &AppHandle) { + mesh_llm_events::set_output_sink(Arc::new(TauriProgressSink { app: app.clone() })); +} diff --git a/desktop/src-tauri/src/mesh_llm/transport_policy.rs b/desktop/src-tauri/src/mesh_llm/transport_policy.rs new file mode 100644 index 0000000000..e4e7ca9fe1 --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/transport_policy.rs @@ -0,0 +1,281 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use iroh::{EndpointAddr, RelayUrl, TransportAddr}; +use mesh_llm_host_runtime::SignedBootstrapToken; + +use super::MESH_IROH_RELAYS_ENV; + +const MAX_INVITE_TOKEN_LEN: usize = 64 * 1024; +const MAX_BOOTSTRAP_ADDRS: usize = 8; +const MAX_ENDPOINT_TRANSPORT_ADDRS: usize = 16; + +/// Locally configured iroh relay policy. Remote discovery may only advertise +/// relay URLs that this node was already configured to contact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum IrohRelayMode { + /// Direct QUIC only; advertised endpoint tokens must not contain relays. + Disabled, + /// Iroh's production relay set, enabled by default for NAT traversal. + Default, + /// An explicit, locally configured relay allowlist. + Custom(Vec), +} + +pub(super) fn iroh_relay_mode() -> anyhow::Result { + iroh_relay_mode_from(std::env::var(MESH_IROH_RELAYS_ENV).ok().as_deref()) +} + +pub(super) fn iroh_relay_mode_from(raw: Option<&str>) -> anyhow::Result { + match raw.map(str::trim) { + Some("0") => Ok(IrohRelayMode::Disabled), + None | Some("") | Some("1") | Some("default") => Ok(IrohRelayMode::Default), + Some(list) => { + let urls = list + .split(',') + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(parse_configured_relay_url) + .collect::>>()?; + if urls.is_empty() { + anyhow::bail!("{MESH_IROH_RELAYS_ENV} must contain at least one relay URL"); + } + Ok(IrohRelayMode::Custom(urls)) + } + } +} + +fn parse_configured_relay_url(raw: &str) -> anyhow::Result { + let parsed = url::Url::parse(raw) + .map_err(|error| anyhow::anyhow!("invalid relay URL {raw:?}: {error}"))?; + let secure = parsed.scheme() == "https"; + let local_http = parsed.scheme() == "http" + && parsed.host().is_some_and(|host| match host { + url::Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"), + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + }); + if !secure && !local_http { + anyhow::bail!( + "relay URL {raw:?} must use https (http is allowed only for loopback development)" + ); + } + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || !matches!(parsed.path(), "" | "/") + { + anyhow::bail!( + "relay URL {raw:?} must be an origin without credentials, path, query, or fragment" + ); + } + raw.parse::() + .map_err(|error| anyhow::anyhow!("invalid iroh relay URL {raw:?}: {error}")) +} + +pub(super) fn validate_advertised_endpoint(invite_token: &str) -> anyhow::Result { + let mode = iroh_relay_mode()?; + validate_advertised_endpoint_with_mode(invite_token, &mode) +} + +pub(super) fn validate_advertised_endpoint_with_mode( + invite_token: &str, + mode: &IrohRelayMode, +) -> anyhow::Result { + let token = invite_token.trim(); + if token.is_empty() { + anyhow::bail!("mesh invite token is empty"); + } + if token.len() > MAX_INVITE_TOKEN_LEN { + anyhow::bail!("mesh invite token exceeds {MAX_INVITE_TOKEN_LEN} bytes"); + } + let payload = URL_SAFE_NO_PAD + .decode(token) + .map_err(|error| anyhow::anyhow!("invalid mesh invite encoding: {error}"))?; + + let addrs = if let Ok(addr) = serde_json::from_slice::(&payload) { + vec![addr] + } else { + let signed = serde_json::from_slice::(&payload) + .map_err(|error| anyhow::anyhow!("invalid mesh invite payload: {error}"))?; + signed + .verify() + .map_err(|reason| anyhow::anyhow!("invalid signed mesh invite: {}", reason.code()))?; + if signed.serialized_addrs.is_empty() || signed.serialized_addrs.len() > MAX_BOOTSTRAP_ADDRS + { + anyhow::bail!( + "signed mesh invite must contain 1..={MAX_BOOTSTRAP_ADDRS} endpoint addresses" + ); + } + signed + .serialized_addrs + .iter() + .map(|bytes| { + serde_json::from_slice::(bytes) + .map_err(|error| anyhow::anyhow!("invalid signed endpoint address: {error}")) + }) + .collect::>>()? + }; + + for addr in &addrs { + validate_endpoint_addr(addr, mode)?; + } + Ok(addrs[0].id.to_string()) +} + +fn validate_endpoint_addr(addr: &EndpointAddr, mode: &IrohRelayMode) -> anyhow::Result<()> { + if addr.addrs.is_empty() || addr.addrs.len() > MAX_ENDPOINT_TRANSPORT_ADDRS { + anyhow::bail!( + "mesh endpoint must contain 1..={MAX_ENDPOINT_TRANSPORT_ADDRS} transport addresses" + ); + } + for transport in &addr.addrs { + match transport { + TransportAddr::Relay(relay) if relay_allowed(relay, mode) => {} + TransportAddr::Relay(relay) => { + anyhow::bail!("mesh endpoint advertises unapproved relay URL {relay}") + } + TransportAddr::Ip(socket) => validate_direct_socket(*socket)?, + _ => anyhow::bail!("mesh endpoint contains an unsupported transport address"), + } + } + Ok(()) +} + +fn relay_allowed(relay: &RelayUrl, mode: &IrohRelayMode) -> bool { + match mode { + IrohRelayMode::Disabled => false, + IrohRelayMode::Default => iroh::defaults::prod::default_relay_map().contains(relay), + IrohRelayMode::Custom(urls) => urls.contains(relay), + } +} + +fn validate_direct_socket(socket: std::net::SocketAddr) -> anyhow::Result<()> { + let ip = socket.ip(); + let unsafe_target = socket.port() == 0 + || ip.is_unspecified() + || ip.is_loopback() + || ip.is_multicast() + || match ip { + std::net::IpAddr::V4(ip) => ip.is_link_local() || ip.is_broadcast(), + std::net::IpAddr::V6(ip) => ip.is_unicast_link_local(), + }; + if unsafe_target { + anyhow::bail!("mesh endpoint advertises unsafe direct address {socket}"); + } + Ok(()) +} + +#[cfg(test)] +pub(super) fn endpoint_token_for_test( + transports: impl IntoIterator, +) -> String { + let mut addr = EndpointAddr::new(iroh::SecretKey::generate().public()); + addr.addrs.extend(transports); + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&addr).expect("serialize test endpoint")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configured_relays_require_safe_origins() { + assert!(iroh_relay_mode_from(Some("https://relay.example")).is_ok()); + assert!(iroh_relay_mode_from(Some("http://127.0.0.1:3340")).is_ok()); + for invalid in [ + "http://relay.example", + "https://user@relay.example", + "https://relay.example/path", + "https://relay.example?token=secret", + ] { + assert!( + iroh_relay_mode_from(Some(invalid)).is_err(), + "accepted unsafe relay {invalid}" + ); + } + } + + #[test] + fn remote_relay_must_match_local_policy() { + let allowed: RelayUrl = "https://relay.example".parse().unwrap(); + let other: RelayUrl = "https://other.example".parse().unwrap(); + let token = endpoint_token_for_test([TransportAddr::Relay(allowed.clone())]); + assert!(validate_advertised_endpoint_with_mode( + &token, + &IrohRelayMode::Custom(vec![allowed]) + ) + .is_ok()); + assert!(validate_advertised_endpoint_with_mode( + &token, + &IrohRelayMode::Custom(vec![other]) + ) + .is_err()); + assert!(validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Disabled).is_err()); + } + + #[test] + fn remote_endpoint_rejects_unsafe_direct_targets_and_oversized_tokens() { + for socket in ["127.0.0.1:9337", "169.254.169.254:80", "0.0.0.0:1"] { + let token = endpoint_token_for_test([TransportAddr::Ip(socket.parse().unwrap())]); + assert!( + validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Default).is_err(), + "accepted unsafe target {socket}" + ); + } + let valid = + endpoint_token_for_test([TransportAddr::Ip("192.168.1.20:47916".parse().unwrap())]); + assert!(validate_advertised_endpoint_with_mode(&valid, &IrohRelayMode::Default).is_ok()); + assert!(validate_advertised_endpoint_with_mode( + &"a".repeat(MAX_INVITE_TOKEN_LEN + 1), + &IrohRelayMode::Default + ) + .is_err()); + } + + #[test] + fn accepts_verified_meshllm_signed_bootstrap_token_and_rejects_tampering() { + use mesh_llm_host_runtime::crypto::OwnerKeypair; + use mesh_llm_host_runtime::{ + MeshGenesisPolicy, MeshRequirements, SignedBootstrapToken, SignedMeshGenesisPolicy, + }; + + let owner = OwnerKeypair::generate(); + let policy = MeshGenesisPolicy::new( + owner.owner_id(), + 1_717_171_717_000, + MeshRequirements::default(), + ) + .expect("create test mesh policy"); + let signed_policy = + SignedMeshGenesisPolicy::sign(policy, &owner).expect("sign test mesh policy"); + let endpoint = EndpointAddr { + id: iroh::SecretKey::generate().public(), + addrs: [TransportAddr::Ip("192.168.1.20:47916".parse().unwrap())] + .into_iter() + .collect(), + }; + let signed = SignedBootstrapToken::sign( + vec![serde_json::to_vec(&endpoint).unwrap()], + &signed_policy, + None, + &owner, + ) + .expect("sign test bootstrap token"); + let token = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&signed).unwrap()); + assert_eq!( + validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Default).unwrap(), + endpoint.id.to_string() + ); + + let mut tampered = signed; + tampered.serialized_addrs[0] = serde_json::to_vec(&EndpointAddr { + id: endpoint.id, + addrs: [TransportAddr::Ip("192.168.1.21:47916".parse().unwrap())] + .into_iter() + .collect(), + }) + .unwrap(); + let token = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&tampered).unwrap()); + assert!(validate_advertised_endpoint_with_mode(&token, &IrohRelayMode::Default).is_err()); + } +} diff --git a/desktop/src-tauri/src/mesh_llm_stubs.rs b/desktop/src-tauri/src/mesh_llm_stubs.rs index 318be3c583..779f7dbddc 100644 --- a/desktop/src-tauri/src/mesh_llm_stubs.rs +++ b/desktop/src-tauri/src/mesh_llm_stubs.rs @@ -4,11 +4,6 @@ use crate::app_state::AppState; type CmdResult = Result; -#[tauri::command] -pub async fn mesh_availability(_state: State<'_, AppState>) -> CmdResult { - Err("mesh-llm feature not enabled".to_string()) -} - #[tauri::command] pub async fn mesh_start_node( _app: tauri::AppHandle, @@ -18,23 +13,6 @@ pub async fn mesh_start_node( Err("mesh-llm feature not enabled".to_string()) } -#[tauri::command] -pub async fn mesh_ensure_client_node( - _state: State<'_, AppState>, - _request: serde_json::Value, -) -> CmdResult { - Err("mesh-llm feature not enabled".to_string()) -} - -#[tauri::command] -pub async fn mesh_prepare_relay_mesh_client( - _app: tauri::AppHandle, - _state: State<'_, AppState>, - _request: serde_json::Value, -) -> CmdResult { - Err("mesh-llm feature not enabled".to_string()) -} - #[tauri::command] pub async fn mesh_stop_node( _app: tauri::AppHandle, @@ -56,21 +34,6 @@ pub async fn mesh_installed_models( } #[tauri::command] -pub fn mesh_agent_preset(_request: serde_json::Value) -> CmdResult { - Err("mesh-llm feature not enabled".to_string()) -} - -#[tauri::command] -pub async fn mesh_dial_endpoint_addr( - _state: State<'_, AppState>, - _request: serde_json::Value, -) -> CmdResult { - Err("mesh-llm feature not enabled".to_string()) -} - -#[tauri::command] -pub async fn mesh_status_report_payload( - _state: State<'_, AppState>, -) -> CmdResult> { +pub async fn mesh_model_catalog() -> CmdResult { Err("mesh-llm feature not enabled".to_string()) } diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 6ee956effe..b0e3359e53 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -18,6 +18,8 @@ use std::path::{Path, PathBuf}; use tauri::Manager; +use crate::util::replace_with_symlink; + const CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.buzz.app.dev"; const LEGACY_CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.sprout.app.dev"; const LEGACY_RELEASE_IDENTIFIER: &str = "xyz.block.sprout.app"; @@ -36,26 +38,24 @@ const SHARED_AGENT_FILES: &[&str] = &[ /// dev data directory. Each entry becomes a single directory symlink. const SHARED_AGENT_DIRS: &[&str] = &["agents/teams"]; -/// Create a symlink at `dst` pointing to `src`. -/// -/// Worktree sync is a dev-only feature (`BUZZ_SHARE_IDENTITY=1`); on Windows -/// this is a no-op so the rest of `sync_shared_agent_data` keeps compiling and -/// running harmlessly. -#[cfg(unix)] -fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> { - std::os::unix::fs::symlink(src, dst) -} - -#[cfg(not(unix))] -fn symlink(_src: &Path, _dst: &Path) -> std::io::Result<()> { - Ok(()) +/// Returns `true` when `name` is a dev data dir name — i.e. it is exactly the +/// canonical dev identifier or a worktree variant separated by a `.` (e.g. +/// `xyz.block.buzz.app.dev.my-branch`). Rejects prefix-collisions such as +/// `xyz.block.buzz.app.developer`. This is the authoritative dev/prod +/// discriminator shared by `run_boot_migrations`, `sync_shared_agent_data`, +/// and `reconcile_target_dir`. +pub(crate) fn is_dev_data_dir_name(name: &str) -> bool { + name == CANONICAL_DEV_IDENTIFIER + || name + .strip_prefix(CANONICAL_DEV_IDENTIFIER) + .is_some_and(|rest| rest.starts_with('.')) } fn canonical_dev_data_dir(current: &Path) -> Option { current.parent().map(|p| p.join(CANONICAL_DEV_IDENTIFIER)) } -fn legacy_app_data_dir(current: &Path) -> Option { +pub(crate) fn legacy_app_data_dir(current: &Path) -> Option { let name = current.file_name()?.to_str()?; let legacy_name = if name.starts_with(CANONICAL_DEV_IDENTIFIER) { name.replacen(CANONICAL_DEV_IDENTIFIER, LEGACY_CANONICAL_DEV_IDENTIFIER, 1) @@ -81,7 +81,7 @@ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> { if dst_path.exists() || dst_path.is_symlink() { let _ = std::fs::remove_file(&dst_path); } - std::os::unix::fs::symlink(target, &dst_path)?; + crate::util::create_symlink(&target, &dst_path)?; } #[cfg(not(unix))] { @@ -101,16 +101,6 @@ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> { Ok(()) } -/// Reconcile personas and teams into signed retention events. Both readers -/// consume the already-synced `personas.json`/`teams.json` that -/// `sync_team_personas` wrote in [`run_boot_migrations`] (see its `# Ordering` -/// guard). Event signing needs the resolved owner keys, so this runs after -/// identity resolution, not in [`run_boot_migrations`]. -pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) { - migrate_personas_to_events(app, owner_keys); - migrate_teams_to_events(app, owner_keys); -} - /// Run every data migration that must complete before identity resolution and /// agent restore. Ordering is load-bearing: `migrate_legacy_app_data_dir` must /// precede any disk read, and `sync_shared_agent_data` must precede @@ -124,10 +114,20 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) { /// files. The pre-identity reader is `reconcile_provider_mcp_commands` (derives /// `mcp_command` from each persona's effective harness); the post-identity /// readers are `migrate_personas_to_events`/`migrate_teams_to_events` in -/// [`run_event_sync`]. Sync touches only JSON (no owner keys, no `retention.db`), -/// so it runs pre-identity here ahead of all readers — reader-first loses a -/// launch (stale harness/`mcp_command` until the next boot). +/// [`crate::event_sync::run_event_sync`]. Sync touches only JSON (no owner +/// keys, no `retention.db`), so it runs pre-identity here ahead of all +/// readers — reader-first loses a launch (stale harness/`mcp_command` until +/// the next boot). pub fn run_boot_migrations(app: &tauri::AppHandle) { + run_boot_migrations_inner(app, false); +} + +/// Entry point when a completed reset must suppress dev-nest re-import. +pub fn run_boot_migrations_after_reset(app: &tauri::AppHandle) { + run_boot_migrations_inner(app, true); +} + +fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { // Initialize the process-lifetime nest directory before any filesystem // operation that calls nest_dir(). The discriminator matches the existing // pattern used by reconcile_target_dir: dev instances have an app-data-dir @@ -136,7 +136,7 @@ pub fn run_boot_migrations(app: &tauri::AppHandle) { let dev = data_dir .file_name() .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with(CANONICAL_DEV_IDENTIFIER)); + .is_some_and(is_dev_data_dir_name); crate::managed_agents::init_nest_dir(dev); dev } else { @@ -148,20 +148,42 @@ pub fn run_boot_migrations(app: &tauri::AppHandle) { // ensures the dev nest boots with the correct workspace on its first launch, // matching what the prod nest had configured. Skip-if-dest-exists so it is // idempotent and never clobbers a value the dev nest already set explicitly. - if is_dev { - migrate_dev_repos_dir(); + // Uses the composed helper so the gate + migration run through the same + // code path that the behavioral test exercises. + if let (Some(home), Some(dev_nest)) = (dirs::home_dir(), crate::managed_agents::nest_dir()) { + maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest); } migrate_legacy_app_data_dir(app); sync_shared_agent_data(app); - migrate_packs_to_teams(app); - reconcile_persona_team_dirs(app); + // Dev-build-only: copy any agent keys that exist in the production + // keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev") + // so existing agents don't lose their keys after the service-name split. + // Must run after sync_shared_agent_data (JSON symlinked) and before + // any load_managed_agents call (which runs hydrate_keys against the + // dev service and would log "has no key" for un-migrated entries). + #[cfg(debug_assertions)] + if is_dev { + crate::managed_agents::migrate_agent_keys_to_dev_service(app); + } migrate_persona_provider_to_runtime(app); reconcile_legacy_command_names(app); - if let Err(e) = crate::managed_agents::sync_team_personas(app) { - eprintln!("buzz-desktop: sync-team-personas: {e}"); - } + // Fold personas.json into the unified store HERE: after the JSON-level + // personas.json migrations above (which must see the legacy file), and + // before every consumer of the load/save_personas shims below — + // sync_team_personas would otherwise operate on an empty definition set. + // Post-fold readers of the runtime map (`load_persona_runtimes`) fall + // back to the unified store's definitions. + fold_personas_into_agent_store(app); + // B5: manufacture definitions for standalone agents AFTER the fold (so + // pre-existing definition slugs are present for collision checks) and + // before event sync republishes — the backfilled link is what flips the + // 30177 projection to its slim shape. + backfill_standalone_agents(app); + detach_directory_backed_teams(app); reconcile_provider_mcp_commands(app); + reconcile_databricks_v1_to_v2(app); + materialize_agent_runtimes(app); } /// Copy one-time app state from the legacy app identifier directory to @@ -261,6 +283,14 @@ fn migrate_legacy_nest_at(legacy: &Path, current: &Path) -> bool { if !legacy.exists() { return false; } + // A deliberate dev reset pre-creates this marker to opt out of every + // production/legacy nest import. Normal first-run migration still copies + // `.sprout` before `migrate_dev_nest()` writes the marker later in boot. + if current.file_name().is_some_and(|name| name == ".buzz-dev") + && current.join(DEV_NEST_MIGRATED_SENTINEL).exists() + { + return false; + } for name in LEGACY_NEST_KNOWLEDGE { let src = legacy.join(name); if !src.exists() { @@ -303,23 +333,27 @@ fn migrate_legacy_nest_at(legacy: &Path, current: &Path) -> bool { /// also copies into `~/.buzz-dev` and could otherwise set the sentinel early. const DEV_NEST_MIGRATED_SENTINEL: &str = ".dev-nest-migrated"; -/// Copy the `.repos-dir` dotfile from `~/.buzz` → `~/.buzz-dev`, non-destructively. -/// -/// Must be called on dev builds BEFORE `resolve_repos_at_boot()` reads the -/// dotfile, so the dev nest boots with the correct workspace configuration on -/// its first launch. Skip-if-dest-exists so it is idempotent and never -/// overwrites a value set directly by the dev nest. -fn migrate_dev_repos_dir() { - let Some(home) = dirs::home_dir() else { - return; - }; +/// Returns true when `migrate_dev_repos_dir` should run: dev build AND no +/// completed reset this boot (a completed reset means the dev nest was just +/// wiped — re-importing from prod would undo the sign-out). +pub(crate) fn should_migrate_dev_repos_dir(is_dev: bool, reset_completed: bool) -> bool { + is_dev && !reset_completed +} + +/// Injectable core: copy `.repos-dir` from `/.buzz/` into `dev_nest`, +/// non-destructively. Extracted so tests can inject temp paths without +/// touching `dirs::home_dir()` or the global `nest_dir()` OnceLock. +pub(crate) fn migrate_dev_repos_dir_at(home: &Path, dev_nest: &Path) { let src = home.join(".buzz").join(".repos-dir"); if !src.exists() { return; } - let Some(dev_nest) = crate::managed_agents::nest_dir() else { + // This is a one-time migration. The full dev-nest migration writes the + // same sentinel, and a deliberate dev reset pre-creates it to keep the + // next launch from importing production workspace state again. + if dev_nest.join(DEV_NEST_MIGRATED_SENTINEL).exists() { return; - }; + } let dst = dev_nest.join(".repos-dir"); // Skip if the dev nest already has its own .repos-dir. if dst.exists() { @@ -327,7 +361,7 @@ fn migrate_dev_repos_dir() { } // Ensure the dev nest directory itself exists — this migration runs before // ensure_nest() in the boot sequence, so the directory may not yet exist. - if let Err(e) = std::fs::create_dir_all(&dev_nest) { + if let Err(e) = std::fs::create_dir_all(dev_nest) { eprintln!( "buzz-desktop: dev-nest-migration: failed to create dev nest {}: {e}", dev_nest.display() @@ -343,6 +377,21 @@ fn migrate_dev_repos_dir() { } } +/// Composed gate + migration: applies `should_migrate_dev_repos_dir` and, if +/// the gate passes, runs the injectable migration core. This is the seam that +/// `run_boot_migrations_inner` calls with real paths — a future mis-wired flag +/// or inverted gate breaks tests at this level, not just the predicate. +pub(crate) fn maybe_migrate_dev_repos_dir( + is_dev: bool, + reset_completed: bool, + home: &Path, + dev_nest: &Path, +) { + if should_migrate_dev_repos_dir(is_dev, reset_completed) { + migrate_dev_repos_dir_at(home, dev_nest); + } +} + /// One-time migration of dev-build nest contents from `~/.buzz` → `~/.buzz-dev`. /// /// When a dev build first boots after this change ships, it switches from the @@ -497,6 +546,23 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { } }; + // Guard: refuse to sync against a prod-identifier data directory, regardless + // of env vars. A release build launched from an env-armed shell (e.g. macOS + // `open` inherits the caller's env) must never overwrite real prod files with + // symlinks. Only data dirs whose name starts with CANONICAL_DEV_IDENTIFIER + // (the canonical dev dir and all worktree variants) are safe targets. + let is_dev = current_dir + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(is_dev_data_dir_name); + if !is_dev { + eprintln!( + "buzz-desktop: shared-agent-sync: skipping — data dir is not a dev dir ({})", + current_dir.display() + ); + return; + } + let canonical_dir = match canonical_dev_data_dir(¤t_dir) { Some(dir) => dir, None => { @@ -585,26 +651,7 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { } } - // Already a correct symlink — nothing to do. - if dst.is_symlink() { - if let Ok(target) = std::fs::read_link(&dst) { - if target == src { - continue; - } - } - } - - // Remove whatever's at dst (regular file, wrong symlink, broken symlink). - if dst.exists() || dst.is_symlink() { - let _ = std::fs::remove_file(&dst); - } - - match symlink(&src, &dst) { - Ok(_) => synced += 1, - Err(e) => { - eprintln!("buzz-desktop: shared-agent-sync: failed to symlink {rel}: {e}"); - } - } + synced += replace_with_symlink(&src, &dst); } // Ensure shared directories exist in canonical before symlinking. @@ -638,8 +685,8 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { } } // Replace the sibling's dir with a symlink to canonical. - let _ = std::fs::remove_dir_all(&sibling_dir); - let _ = symlink(&canonical_target, &sibling_dir); + // replace_with_symlink backs up any leftover real content. + replace_with_symlink(&canonical_target, &sibling_dir); eprintln!( "buzz-desktop: shared-agent-sync: migrated {rel} from {}", sibling.display() @@ -670,26 +717,7 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { } } - if dst.is_symlink() { - if let Ok(target) = std::fs::read_link(&dst) { - if target == src { - continue; - } - } - } - - if dst.is_symlink() { - let _ = std::fs::remove_file(&dst); - } else if dst.exists() { - let _ = std::fs::remove_dir_all(&dst); - } - - match symlink(&src, &dst) { - Ok(_) => synced += 1, - Err(e) => { - eprintln!("buzz-desktop: shared-agent-sync: failed to symlink {rel}: {e}"); - } - } + synced += replace_with_symlink(&src, &dst); } if synced > 0 { @@ -700,294 +728,6 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { } } -fn reconcile_team_dirs_in_file(path: &Path, target_dir: &Path) { - // Build per-component so the persisted value uses native separators on - // every platform, matching fresh writes (agents.rs builds the same path as - // base.join("teams").join(id)). A single join("agents/teams") would embed a - // literal '/' on Windows, persisting a mixed-separator path into the store. - let target_teams = target_dir.join("agents").join("teams"); - patch_json_records(path, |obj| { - // Handle both old field name and new field name - let field_name = if obj.contains_key("persona_team_dir") { - "persona_team_dir" - } else if obj.contains_key("persona_pack_path") { - "persona_pack_path" - } else { - return false; - }; - let team_path = match obj.get(field_name).and_then(|v| v.as_str()) { - Some(p) => p, - None => return false, - }; - let team_path = Path::new(team_path); - // Extract the team ID from the path (component after "teams" or "packs") - let mut found_dir = false; - let mut team_id: Option<&std::ffi::OsStr> = None; - for component in team_path.components() { - if found_dir { - team_id = Some(component.as_os_str()); - break; - } - if component.as_os_str() == "teams" || component.as_os_str() == "packs" { - found_dir = true; - } - } - let Some(id) = team_id else { - return false; - }; - let expected = target_teams.join(id); - if team_path == expected { - // Value already correct — still normalize the legacy field name so - // stores converge on `persona_team_dir` (runtime reads either via - // serde alias). - if field_name == "persona_pack_path" { - if let Some(val) = obj.remove("persona_pack_path") { - obj.insert("persona_team_dir".to_string(), val); - return true; - } - } - return false; - } - // Rewriting to a path that does not exist on disk makes things worse - // than leaving a stale-but-working path in place. fs::metadata follows - // symlinks, so a valid symlinked install passes; a dangling symlink - // fails with NotFound. - if let Err(e) = std::fs::metadata(&expected) { - eprintln!( - "buzz-desktop: team-dir-reconcile: {:?}: {:?} expected at {:?} — {e}, leaving as-is", - obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), - team_path, - expected, - ); - return false; - } - let Some(expected_str) = expected.to_str() else { - eprintln!( - "buzz-desktop: team-dir-reconcile: {:?}: expected path {:?} is not valid UTF-8, leaving as-is", - obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), - expected, - ); - return false; - }; - eprintln!( - "buzz-desktop: team-dir-reconcile: {:?}: {:?} → {:?}", - obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), - team_path, - expected, - ); - // Always write the new field name - obj.remove("persona_pack_path"); - obj.insert( - "persona_team_dir".to_string(), - serde_json::Value::String(expected_str.to_owned()), - ); - true - }); -} - -/// Select the data directory to reconcile against. -/// -/// Dev instances — identified by the data-dir name starting with -/// `CANONICAL_DEV_IDENTIFIER` (covers the canonical dir itself and any -/// worktree variant like `xyz.block.buzz.app.dev.mybranch`) — share -/// `agents/managed-agents.json` and `agents/teams` via symlinks to the -/// canonical dev dir, so they should normalize against that canonical dir. -/// -/// Release builds must reconcile their own data dir — keying off the canonical -/// dev dir's mere existence would leave release records permanently stale on -/// developer machines, where that dir is always present. -fn reconcile_target_dir(current_dir: &Path) -> PathBuf { - let is_dev_instance = current_dir - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with(CANONICAL_DEV_IDENTIFIER)); - if is_dev_instance { - match canonical_dev_data_dir(current_dir) { - Some(dir) if dir.exists() => dir, - _ => current_dir.to_path_buf(), - } - } else { - current_dir.to_path_buf() - } -} - -/// Reconcile `persona_team_dir` (and legacy `persona_pack_path`) values in -/// managed-agents.json to point to the correct `agents/teams/` prefix. -/// -/// Fixes two classes of stale paths: -/// - Worktree dev instances whose records point at a sibling data dir rather -/// than the canonical dev dir (dev instances share managed-agents.json and -/// agents/teams via symlinks, so they all reconcile against the canonical dir). -/// - Legacy paths left by historical renames: `agents/packs/` → `agents/teams/` -/// (the packs→teams consolidation) and bundle-id `xyz.block.sprout.app` → -/// `xyz.block.buzz.app` (the sprout→buzz rename, which moved the app data dir). -/// -/// Release builds reconcile their own data dir — choosing the canonical dev dir -/// whenever it exists would leave release files permanently stale on developer -/// machines. -pub fn reconcile_persona_team_dirs(app: &tauri::AppHandle) { - let Ok(current_dir) = app.path().app_data_dir() else { - return; - }; - // Single-dir on purpose: unlike reconcile_legacy_command_names and - // reconcile_provider_mcp_commands, which patch both [current, canonical], - // path rewrites are target-dependent — a dual pass through a dev - // instance's symlinked store would write worktree-local paths into the - // shared canonical file. - let target_dir = reconcile_target_dir(¤t_dir); - let path = target_dir.join("agents/managed-agents.json"); - if !path.exists() { - return; - } - reconcile_team_dirs_in_file(&path, &target_dir); -} - -/// One-time migration from packs to teams. -/// -/// Runs on app launch if `agents/packs/` exists or if any record in -/// `managed-agents.json` still uses the old `persona_pack_path` field name. -/// Steps (in order, each individually idempotent): -/// -/// 1. Rename `agents/packs/` → `agents/teams/` on disk -/// 2. Rewrite `personas.json`: `source_pack` → `source_team`, `source_pack_persona_slug` → `source_team_persona_slug` -/// 3. Rewrite `managed-agents.json`: `persona_pack_path` → `persona_team_dir` (with `/packs/` → `/teams/` path fix), `persona_name_in_pack` → `persona_name_in_team` -pub fn migrate_packs_to_teams(app: &tauri::AppHandle) { - use crate::managed_agents::MigrationReport; - - let Ok(current_dir) = app.path().app_data_dir() else { - return; - }; - let target_dir = reconcile_target_dir(¤t_dir); - - let packs_dir = target_dir.join("agents/packs"); - let teams_dir = target_dir.join("agents/teams"); - let personas_path = target_dir.join("agents/personas.json"); - let agents_path = target_dir.join("agents/managed-agents.json"); - - // Check if migration is needed: packs dir exists OR agents JSON has old field names - let packs_dir_exists = packs_dir.exists() && !packs_dir.is_symlink(); - let has_old_fields = agents_path.exists() - && std::fs::read_to_string(&agents_path) - .map(|c| c.contains("persona_pack_path")) - .unwrap_or(false); - let personas_has_old_fields = personas_path.exists() - && std::fs::read_to_string(&personas_path) - .map(|c| c.contains("\"source_pack\"")) - .unwrap_or(false); - - if !packs_dir_exists && !has_old_fields && !personas_has_old_fields { - return; - } - - let mut report = MigrationReport { - packs_migrated: 0, - personas_updated: 0, - agents_updated: 0, - errors: Vec::new(), - }; - - // Step 1: Rename directory agents/packs/ → agents/teams/ - if packs_dir_exists { - if teams_dir.exists() { - // Merge: move contents from packs into teams, skip conflicts - if let Ok(entries) = std::fs::read_dir(&packs_dir) { - for entry in entries.flatten() { - let dest = teams_dir.join(entry.file_name()); - if !dest.exists() { - if let Err(e) = std::fs::rename(entry.path(), &dest) { - report - .errors - .push(format!("failed to move {:?}: {e}", entry.file_name())); - } else { - report.packs_migrated += 1; - } - } - } - } - // Remove packs dir only if empty (external tools like ai-rules - // may have recreated symlinks here between migration runs) - let _ = std::fs::remove_dir(&packs_dir); - } else { - // Simple rename - if let Some(parent) = teams_dir.parent() { - let _ = std::fs::create_dir_all(parent); - } - match std::fs::rename(&packs_dir, &teams_dir) { - Ok(_) => { - if let Ok(entries) = std::fs::read_dir(&teams_dir) { - report.packs_migrated = entries.count(); - } - } - Err(e) => { - report - .errors - .push(format!("failed to rename packs → teams: {e}")); - eprintln!("buzz-desktop: packs→teams migration: directory rename failed: {e}"); - return; - } - } - } - } - - // Step 2: Rewrite personas.json field names - if personas_path.exists() { - patch_json_records(&personas_path, |obj| { - let mut changed = false; - if let Some(val) = obj.remove("source_pack") { - obj.insert("source_team".to_string(), val); - changed = true; - } - if let Some(val) = obj.remove("source_pack_persona_slug") { - obj.insert("source_team_persona_slug".to_string(), val); - changed = true; - } - if changed { - report.personas_updated += 1; - } - changed - }); - } - - // Step 3: Rewrite managed-agents.json field names and paths - if agents_path.exists() { - patch_json_records(&agents_path, |obj| { - let mut changed = false; - if let Some(val) = obj.remove("persona_pack_path") { - // Also fix the path: replace /packs/ with /teams/ - let new_val = if let Some(s) = val.as_str() { - serde_json::Value::String(s.replace("/packs/", "/teams/")) - } else { - val - }; - obj.insert("persona_team_dir".to_string(), new_val); - changed = true; - } - if let Some(val) = obj.remove("persona_name_in_pack") { - obj.insert("persona_name_in_team".to_string(), val); - changed = true; - } - if changed { - report.agents_updated += 1; - } - changed - }); - } - - if report.packs_migrated > 0 || report.personas_updated > 0 || report.agents_updated > 0 { - eprintln!( - "buzz-desktop: packs→teams migration complete: {} dirs, {} personas, {} agents{}", - report.packs_migrated, - report.personas_updated, - report.agents_updated, - if report.errors.is_empty() { - String::new() - } else { - format!(" ({} errors)", report.errors.len()) - } - ); - } -} - fn reconcile_mcp_commands_in_file(path: &Path) { // Resolve each record's EFFECTIVE harness (persona-wins, override-honored) // before deriving its mcp_command, so a persona-inherited harness switch @@ -1044,31 +784,6 @@ fn reconcile_mcp_commands_in_file(path: &Path) { }); } -/// Build a `persona_id → runtime` map from the personas.json sibling of the -/// given managed-agents.json path. Returns an empty map when personas can't be -/// read or parsed — callers then fall back to the record's own snapshot. -fn load_persona_runtimes(agents_path: &Path) -> std::collections::HashMap { - let mut map = std::collections::HashMap::new(); - let Some(personas_path) = agents_path.parent().map(|dir| dir.join("personas.json")) else { - return map; - }; - let Ok(content) = std::fs::read_to_string(&personas_path) else { - return map; - }; - let Ok(records) = serde_json::from_str::>(&content) else { - return map; - }; - for record in records { - if let (Some(id), Some(runtime)) = ( - record.get("id").and_then(|v| v.as_str()), - record.get("runtime").and_then(|v| v.as_str()), - ) { - map.insert(id.to_string(), runtime.to_string()); - } - } - map -} - fn replace_command_field( obj: &mut serde_json::Map, field: &str, @@ -1276,6 +991,114 @@ pub fn reconcile_provider_mcp_commands(app: &tauri::AppHandle) { } } +fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool) { + use crate::managed_agents::is_derived_provider_model_key; + patch_json_records(path, |obj| { + let mut changed = false; + + // Only rewrite the structured provider field when the baked build env + // marks this as a Block build (BUZZ_AGENT_PROVIDER == "databricks_v2"). + // OSS users may intentionally select V1 (Model Serving), so we must not + // silently migrate their provider to V2 (AI Gateway). + if rewrite_v1_provider && obj.get("provider").and_then(|v| v.as_str()) == Some("databricks") + { + let name = obj + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("?") + .to_string(); + eprintln!( + "buzz-desktop: databricks-v1-to-v2: {name:?}: provider \"databricks\" → \"databricks_v2\"", + ); + obj.insert( + "provider".to_string(), + serde_json::Value::String("databricks_v2".to_string()), + ); + // Also clear the model field — a V1 model name (e.g. "dbrx-instruct") + // on a V2 provider would shadow the baked DATABRICKS_MODEL at spawn time + // (BUZZ_AGENT_MODEL from runtime_metadata_env_vars takes priority in + // buzz-agent config.rs). Clearing it lets the baked V2 default win. + if obj.remove("model").is_some() { + eprintln!( + "buzz-desktop: databricks-v1-to-v2: {name:?}: cleared stale V1 model field", + ); + } + changed = true; + } + + // Strip derived provider/model keys from env_vars on ALL records, + // regardless of rewrite_v1_provider. These keys are re-derived from + // structured fields at spawn time; stale copies in env_vars silently + // override the structured fields (last-write-wins in Command::env) and + // can cause V1 routing even when the provider dropdown shows V2. + // + // The check is case-insensitive (matching the established helper) + // to cover any case-variant that may have been written historically. + if let Some(serde_json::Value::Object(env_vars)) = obj.get_mut("env_vars") { + let stale_keys: Vec = env_vars + .keys() + .filter(|k| is_derived_provider_model_key(k)) + .cloned() + .collect(); + for key in stale_keys { + env_vars.remove(key.as_str()); + eprintln!("buzz-desktop: databricks-v1-to-v2: removed stale env_vars[\"{key}\"]",); + changed = true; + } + } + + changed + }); +} + +/// Strip stale derived provider/model keys from `env_vars` in all +/// managed-agent records, and — on Block builds — also migrate any persisted +/// `provider: "databricks"` to `"databricks_v2"`. +/// +/// **Block builds** (where `baked_build_env()` contains +/// `BUZZ_AGENT_PROVIDER=databricks_v2`): the structured `provider` field is +/// rewritten V1→V2 because the baked release targets V2 exclusively. Records +/// that were saved before this migration would otherwise silently override the +/// baked value at spawn time (last-write-wins in `Command::env`). +/// +/// **OSS builds** (baked env empty): the `provider` field is left alone — +/// V1 (`databricks`) is a valid Model Serving choice for OSS users. +/// +/// In both cases, stale `BUZZ_AGENT_PROVIDER` / `BUZZ_AGENT_MODEL` / +/// `GOOSE_PROVIDER` / `GOOSE_MODEL` are stripped from `env_vars`. These keys +/// are always re-derived from structured fields at spawn time; persisted copies +/// silence UI edits and cause stale routing. +/// +/// Covers both the current app data dir and the canonical dev data dir +/// (for worktree instances) — same dual-dir pattern as +/// `reconcile_legacy_command_names` and `reconcile_provider_mcp_commands`. +pub fn reconcile_databricks_v1_to_v2(app: &tauri::AppHandle) { + use crate::managed_agents::baked_build_env; + // On Block builds, the baked env contains BUZZ_AGENT_PROVIDER=databricks_v2. + // Use that as a reliable signal that this is a Block build and the V1 + // provider should be migrated. OSS builds have an empty baked env, so + // rewrite_v1_provider is false and the structured provider is preserved. + let rewrite_v1_provider = baked_build_env() + .get("BUZZ_AGENT_PROVIDER") + .map(|v| v == "databricks_v2") + .unwrap_or(false); + let Ok(current_dir) = app.path().app_data_dir() else { + return; + }; + let mut dirs = vec![current_dir.clone()]; + if let Some(canonical) = canonical_dev_data_dir(¤t_dir) { + if canonical.exists() && canonical != current_dir { + dirs.push(canonical); + } + } + for dir in dirs { + let path = dir.join("agents/managed-agents.json"); + if path.exists() { + reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider); + } + } +} + fn rename_provider_to_runtime_in_personas(path: &Path) { patch_json_records(path, |obj| { if obj.contains_key("runtime") { @@ -1301,257 +1124,15 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) { rename_provider_to_runtime_in_personas(&path); } -/// Reconcile `personas.json` into the persona-event retention store. -/// -/// Must run AFTER `migrate_packs_to_teams` (depends on field renames being -/// complete) and AFTER the persisted identity is resolved (it signs every -/// retained event with the owner's keys). -/// -/// Per-record reconcile: for each non-builtin persona it compares the freshly -/// serialized event content against the retained row at the same coordinate -/// and re-retains (marking `pending_sync = 1`) only when the row is absent or -/// its content differs. An unchanged persona is left untouched, so a launch -/// after a no-op edit does not churn `pending_sync`; a persona added or edited -/// on disk between launches is picked up and republished. There is no -/// whole-store sentinel — comparing per coordinate is what lets newly added -/// personas reach the relay. -/// -/// Strategy: write to local SQLite retention first (durable copy), mark as -/// `pending_sync = 1` for later relay publish. Migration succeeds on local -/// write, not relay acknowledgment. Every retained row is a real signed -/// event — there is no placeholder path. -pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { - use crate::managed_agents::managed_agents_base_dir; - - let Ok(base_dir) = managed_agents_base_dir(app) else { - return; - }; - - match migrate_personas_in_dir(&base_dir, keys) { - Ok(0) => {} - Ok(migrated) => { - eprintln!( - "buzz-desktop: persona-event-migration: {migrated} personas migrated to retention" - ); - } - Err(e) => { - eprintln!("buzz-desktop: persona-event-migration: {e}"); - } - } -} - -/// Core reconcile logic, decoupled from the Tauri `AppHandle` for testing. -/// -/// Returns the number of personas (re)written to the retention store. Returns -/// `Ok(0)` when every non-builtin persona already has a matching retained row -/// (or there are none to reconcile). -fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { - use crate::managed_agents::{ - persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, - PersonaRecord, - }; - use buzz_core_pkg::kind::KIND_PERSONA; - use nostr::JsonUtil; - - let pubkey = keys.public_key().to_hex(); - - // Read personas.json fresh at reconcile time. Nothing to do if absent. - let personas_path = base_dir.join("personas.json"); - if !personas_path.exists() { - return Ok(0); - } - - let content = std::fs::read_to_string(&personas_path) - .map_err(|e| format!("failed to read personas.json: {e}"))?; - - let records: Vec = serde_json::from_str(&content) - .map_err(|e| format!("failed to parse personas.json: {e}"))?; - - if records.is_empty() { - return Ok(0); - } - - // Open (or create) the retention database. - let db_path = base_dir.join("retention.db"); - let conn = - open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; - - let mut migrated = 0u32; - - for record in &records { - // Skip built-in personas — they're always available from code. - if record.is_builtin { - continue; - } - - let d_tag = persona_d_tag(record); - - // Fetch the retained head first so the rebuilt event can supersede it: - // build at the default `now` and a future-dated head (clock skew, or an - // interactive same-second `max(now, head+1)` bump) would make - // `retain_event`'s `created_at >= ...` guard SILENTLY skip the UPDATE - // while `migrated` over-reports. Mirror the interactive sites' monotonic - // bump (F1) so a changed body always lands. - let existing = get_retained_event(&conn, KIND_PERSONA, &pubkey, &d_tag)?; - - let event = build_persona_event(record) - .map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))? - .custom_created_at(monotonic_created_at( - existing.as_ref().map(|row| row.created_at), - )) - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign event for '{}': {e}", record.display_name))?; - - // Per-coordinate reconcile: skip when an identical body is already - // retained, so an unchanged persona doesn't reset `pending_sync`. - // Content is timestamp-independent, so the monotonic bump above never - // forces a spurious republish. - let event_content = event.content.to_string(); - if existing - .as_ref() - .is_some_and(|row| row.content == event_content) - { - continue; - } - - let retained = RetainedEvent { - kind: KIND_PERSONA, - pubkey: pubkey.clone(), - d_tag, - content: event_content, - // Safety: nostr timestamps are seconds and stay below i64::MAX - // until year 2262. - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }; - - // The monotonic bump guarantees `created_at > head`, so the upsert's - // `>=` guard always lands the UPDATE — `migrated` counts only real, - // retained republishes. - retain_event(&conn, &retained) - .map_err(|e| format!("failed to retain '{}': {e}", record.display_name))?; - migrated += 1; - } - - Ok(migrated) -} - -/// Reconcile `teams.json` into kind:30176 team events in the retention store. -/// -/// Mirrors [`migrate_personas_to_events`] for teams: it picks up team metadata -/// edits (name/description/persona_ids) made on disk between launches and -/// queues them for relay publish. Managed agents (kind:30177) are deliberately -/// NOT reconciled here — they have no pack/dir source and are backfilled from -/// `managed-agents.json` elsewhere. -/// -/// Must run after the persisted identity is resolved (it signs each event with -/// the owner's keys). -pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { - use crate::managed_agents::managed_agents_base_dir; - - let Ok(base_dir) = managed_agents_base_dir(app) else { - return; - }; - - match migrate_teams_in_dir(&base_dir, keys) { - Ok(0) => {} - Ok(migrated) => { - eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention"); - } - Err(e) => { - eprintln!("buzz-desktop: team-event-migration: {e}"); - } - } -} - -/// Core team reconcile logic, decoupled from the Tauri `AppHandle` for testing. -/// -/// Returns the number of teams (re)written to the retention store. The -/// per-coordinate content compare matches [`migrate_personas_in_dir`]: an -/// unchanged team is skipped so a launch does not churn `pending_sync`. -fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { - use crate::managed_agents::{ - persona_events::monotonic_created_at, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, - team_events::build_team_event, - TeamRecord, - }; - use buzz_core_pkg::kind::KIND_TEAM; - use nostr::JsonUtil; - - let pubkey = keys.public_key().to_hex(); - - let teams_path = base_dir.join("teams.json"); - if !teams_path.exists() { - return Ok(0); - } - - let content = std::fs::read_to_string(&teams_path) - .map_err(|e| format!("failed to read teams.json: {e}"))?; - - let records: Vec = - serde_json::from_str(&content).map_err(|e| format!("failed to parse teams.json: {e}"))?; - - if records.is_empty() { - return Ok(0); - } - - let db_path = base_dir.join("retention.db"); - let conn = - open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; - - let mut migrated = 0u32; - - for record in &records { - // Skip built-in teams — they're always available from code. - if record.is_builtin { - continue; - } - - // Team d-tag is the team id (team_events.rs: no slug fallback). - let d_tag = record.id.clone(); - - // Fetch the head first so the monotonic bump can supersede a - // future-dated head — see migrate_personas_in_dir (F1/F8). - let existing = get_retained_event(&conn, KIND_TEAM, &pubkey, &d_tag)?; - - let event = build_team_event(record) - .map_err(|e| format!("failed to build event for team '{}': {e}", record.name))? - .custom_created_at(monotonic_created_at( - existing.as_ref().map(|row| row.created_at), - )) - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign event for team '{}': {e}", record.name))?; - - let event_content = event.content.to_string(); - if existing - .as_ref() - .is_some_and(|row| row.content == event_content) - { - continue; - } - - let retained = RetainedEvent { - kind: KIND_TEAM, - pubkey: pubkey.clone(), - d_tag, - content: event_content, - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }; - - // Monotonic bump guarantees the upsert UPDATE lands — `migrated` counts - // only real republishes. - retain_event(&conn, &retained) - .map_err(|e| format!("failed to retain team '{}': {e}", record.name))?; - migrated += 1; - } - - Ok(migrated) -} +mod materialize; +pub use materialize::materialize_agent_runtimes; +mod fold; +pub use fold::fold_personas_into_agent_store; +use fold::load_persona_runtimes; +mod backfill; +pub use backfill::backfill_standalone_agents; +mod detach; +pub use detach::detach_directory_backed_teams; #[cfg(test)] #[path = "migration_test_support.rs"] @@ -1565,10 +1146,14 @@ mod tests; #[path = "migration_command_tests.rs"] mod command_tests; +#[cfg(test)] +#[path = "migration_databricks_tests.rs"] +mod databricks_tests; + #[cfg(test)] #[path = "migration_team_dir_tests.rs"] mod team_dir_tests; #[cfg(test)] -#[path = "migration_team_events_tests.rs"] -mod team_events_tests; +#[path = "migration_sync_guard_tests.rs"] +mod sync_guard_tests; diff --git a/desktop/src-tauri/src/migration/backfill.rs b/desktop/src-tauri/src/migration/backfill.rs new file mode 100644 index 0000000000..b7f85df138 --- /dev/null +++ b/desktop/src-tauri/src/migration/backfill.rs @@ -0,0 +1,139 @@ +//! B5 (unified agent model): one-time backfill of standalone agents into +//! definition-linked records. Every keyed record with `persona_id: None` +//! gets a key-less definition manufactured from its own settings, retiring +//! the standalone-create pattern (Wes's Option 2: backfill, not grandfather). + +use std::path::Path; + +use crate::managed_agents::{ + persona_events::{persona_content_hash, persona_event_content}, + ManagedAgentRecord, +}; + +/// Manufacture definitions for standalone agents (B5 backfill). +/// +/// For each keyed record with `persona_id: None`, append a key-less +/// definition record snapshotting the agent's own config and link the agent +/// to it. Safety rails (pinned in the B5 review gates): +/// - **Idempotent**: linked records are skipped, so a second run is a no-op. +/// - **`.bak` create-if-absent**: the pre-migration backup is taken once and +/// never clobbered — a partial first run must not replace the pristine +/// backup with a half-migrated snapshot on re-run. +/// - **Fail loudly per record**: a record that cannot be backfilled (slug +/// collision) is logged and skipped; the rest proceed. +/// - **No behavior change**: the definition snapshots the record's own +/// values (prompt present-even-if-empty via `to_definition_view`'s +/// `unwrap_or_default`, env COPIED so later instances inherit a working +/// config, quad copied to the definition defaults) and the record gains +/// `persona_source_version` = the new definition's content hash, so +/// neither `spawn_config_hash` nor the drift badge moves. +/// +/// The manufactured definition's slug is the agent's pubkey: 64-hex passes +/// the NIP-AP slug grammar on both relay and desktop ends, and agent pubkeys +/// are unique, so the coordinate is collision-free by construction. +pub fn backfill_standalone_agents(app: &tauri::AppHandle) { + let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { + return; + }; + match backfill_standalone_agents_in_dir(&base_dir) { + Ok(0) => {} + Ok(backfilled) => { + eprintln!( + "buzz-desktop: standalone-backfill: {backfilled} agents linked to manufactured definitions" + ); + } + Err(e) => eprintln!("buzz-desktop: standalone-backfill: {e}"), + } +} + +/// Core backfill logic, decoupled from the Tauri `AppHandle` for testing. +/// Returns the number of records backfilled (0 = nothing to do). +fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result { + let agents_path = base_dir.join("managed-agents.json"); + if !agents_path.exists() { + return Ok(0); + } + let content = std::fs::read_to_string(&agents_path) + .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; + let mut all: Vec = serde_json::from_str(&content) + .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; + + let needs_backfill = + |record: &ManagedAgentRecord| !record.pubkey.is_empty() && record.persona_id.is_none(); + if !all.iter().any(needs_backfill) { + return Ok(0); + } + + // Pre-migration backup, taken ONCE: a re-run after a partial failure must + // not overwrite the pristine backup with a half-migrated snapshot. + let bak_path = base_dir.join("managed-agents.json.pre-backfill.bak"); + if !bak_path.exists() { + std::fs::write(&bak_path, &content) + .map_err(|e| format!("failed to write pre-backfill backup: {e}"))?; + } + + let existing_slugs: std::collections::HashSet = + all.iter().filter_map(|r| r.slug.clone()).collect(); + + let mut manufactured: Vec = Vec::new(); + let mut backfilled = 0usize; + for record in all.iter_mut().filter(|r| needs_backfill(r)) { + // Pubkeys are unique so this cannot fire against another manufactured + // definition — only against a pre-existing definition improbably + // slugged as this agent's pubkey. Fail loudly, skip, continue: the + // agent keeps working persona-less (`persona_id: None`), and the + // backfill retries it on every boot. Recovery path: delete or re-slug + // the colliding definition, then relaunch. + if existing_slugs.contains(&record.pubkey) { + eprintln!( + "buzz-desktop: standalone-backfill: slug collision for agent {} — skipped; \ + delete or re-slug the colliding definition to let the next launch backfill it", + record.pubkey + ); + continue; + } + + // Snapshot the record's own config as a definition. Via the same + // fold path every definition takes: a temporary persona view of the + // record (prompt unwrap_or_default = present-even-if-empty — the + // heal source old devices hard-require) folded into a key-less + // definition record. Quad + env come along so future instances + // minted from this definition inherit a working config. + let mut view_source = record.clone(); + view_source.slug = Some(record.pubkey.clone()); + // Standalone agents have no definition-level quad — the INSTANCE + // fields are the author's intent; copy them up. + view_source.definition_respond_to = Some(record.respond_to.as_str().to_string()); + view_source.definition_respond_to_allowlist = record.respond_to_allowlist.clone(); + view_source.definition_parallelism = Some(record.parallelism); + let Some(persona_view) = view_source.to_definition_view() else { + eprintln!( + "buzz-desktop: standalone-backfill: agent {} produced no persona view — skipped", + record.pubkey + ); + continue; + }; + + // Link the record BEFORE computing the version so the hash covers the + // definition exactly as manufactured. + let source_version = persona_content_hash(&persona_event_content(&persona_view)); + let definition = persona_view.into_agent_record(); + record.persona_id = Some(record.pubkey.clone()); + record.persona_source_version = Some(source_version); + manufactured.push(definition); + backfilled += 1; + } + + if backfilled == 0 { + return Ok(0); + } + all.extend(manufactured); + let payload = serde_json::to_vec_pretty(&all) + .map_err(|e| format!("failed to serialize unified store: {e}"))?; + crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?; + Ok(backfilled) +} + +#[cfg(test)] +#[path = "backfill_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs new file mode 100644 index 0000000000..9d2731d241 --- /dev/null +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -0,0 +1,301 @@ +use super::backfill_standalone_agents_in_dir; +use crate::managed_agents::spawn_hash::spawn_config_hash; +use crate::managed_agents::{AgentDefinition, ManagedAgentRecord}; +use crate::migration::test_support::{read_agents_json, write_agents_json}; +use std::path::Path; + +fn standalone_agent_json(name: &str, pubkey: &str, prompt: Option<&str>) -> serde_json::Value { + serde_json::json!({ + "name": name, + "pubkey": pubkey, + "relay_url": "ws://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "parallelism": 4, + "system_prompt": prompt, + "model": "gpt-x", + "provider": "openai", + "respond_to": "anyone", + "env_vars": { "API_KEY": "secret" }, + "start_on_app_launch": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }) +} + +fn load_typed(dir: &Path) -> Vec { + let content = std::fs::read_to_string(dir.join("agents").join("managed-agents.json")).unwrap(); + serde_json::from_str(&content).unwrap() +} + +fn base(dir: &Path) -> std::path::PathBuf { + dir.join("agents") +} + +#[test] +fn backfill_links_standalone_agent_to_manufactured_definition() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "a".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([standalone_agent_json( + "Solo", + &pubkey, + Some("You are Solo.") + )]), + ); + + let backfilled = backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + assert_eq!(backfilled, 1); + + let records = load_typed(dir.path()); + assert_eq!(records.len(), 2, "instance + manufactured definition"); + + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(pubkey.as_str())); + assert!(instance.persona_source_version.is_some()); + + let definition = records.iter().find(|r| r.pubkey.is_empty()).unwrap(); + assert_eq!(definition.slug.as_deref(), Some(pubkey.as_str())); + assert_eq!(definition.system_prompt.as_deref(), Some("You are Solo.")); + assert_eq!(definition.model.as_deref(), Some("gpt-x")); + // Env COPIED (B5 pin): later instances inherit a working config. + assert_eq!( + definition.env_vars.get("API_KEY").map(String::as_str), + Some("secret") + ); + // Instance quad copied up as the definition's defaults. + assert_eq!(definition.definition_respond_to.as_deref(), Some("anyone")); + assert_eq!(definition.definition_parallelism, Some(4)); + + // The recorded version matches the definition's actual content hash — + // the drift badge starts clean. + let view = definition.to_definition_view().unwrap(); + let expected = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(&view), + ); + assert_eq!( + instance.persona_source_version.as_deref(), + Some(expected.as_str()) + ); +} + +#[test] +fn backfilled_definition_carries_prompt_present_even_if_empty() { + // LOAD-BEARING (B5 gates): old readers hard-fail on an absent prompt. A + // prompt-less backfilled definition would leave a wiped old device with + // no heal source, permanently. `AgentDefinition.system_prompt` is a plain + // String and the outbound projection wraps it in `Some` unconditionally + // — this row pins that chain against refactors. + let dir = tempfile::tempdir().unwrap(); + let pubkey = "b".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([standalone_agent_json("NoPrompt", &pubkey, None)]), + ); + + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + + let records = load_typed(dir.path()); + let definition = records.iter().find(|r| r.pubkey.is_empty()).unwrap(); + let view: AgentDefinition = definition.to_definition_view().unwrap(); + assert_eq!(view.system_prompt, "", "empty, not absent"); + let content = crate::managed_agents::persona_events::persona_event_content(&view); + assert_eq!( + content.system_prompt.as_deref(), + Some(""), + "wire projection must carry Some(\"\") — the old-reader heal source" + ); +} + +#[test] +fn backfill_of_promptless_record_keeps_spawn_hash_stable() { + // B5 hash row 2: pre-backfill the record hashes prompt None; post-backfill + // the prospective re-snapshot pulls Some("") from the manufactured + // definition. The spawn layer treats an empty prompt as no prompt (env + // absent either way), so the hash must not move — otherwise every + // prompt-less standalone agent lights the restart badge on upgrade. + let dir = tempfile::tempdir().unwrap(); + let pubkey = "c".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([standalone_agent_json("NoPrompt", &pubkey, None)]), + ); + + let pre_records = load_typed(dir.path()); + let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + let hash_before = spawn_config_hash( + pre_instance, + &[], + &[], + "wss://ws.example", + &Default::default(), + ); + + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + + let post_records = load_typed(dir.path()); + let post_instance = post_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + let personas: Vec = post_records + .iter() + .filter_map(|r| r.to_definition_view()) + .collect(); + let hash_after = spawn_config_hash( + post_instance, + &personas, + &[], + "wss://ws.example", + &Default::default(), + ); + + assert_eq!( + hash_before, hash_after, + "backfill must not flip the restart badge for prompt-less agents" + ); +} + +#[test] +fn backfill_of_prompted_record_keeps_spawn_hash_stable() { + // The general no-behavior-change rail: a standalone agent WITH config + // must also hash identically across backfill (the definition snapshots + // the record's own values, so the re-snapshot writes back what is + // already there). + let dir = tempfile::tempdir().unwrap(); + let pubkey = "d".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([standalone_agent_json( + "Solo", + &pubkey, + Some("You are Solo.") + )]), + ); + + let pre_records = load_typed(dir.path()); + let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + let hash_before = spawn_config_hash( + pre_instance, + &[], + &[], + "wss://ws.example", + &Default::default(), + ); + + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + + let post_records = load_typed(dir.path()); + let post_instance = post_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + let personas: Vec = post_records + .iter() + .filter_map(|r| r.to_definition_view()) + .collect(); + let hash_after = spawn_config_hash( + post_instance, + &personas, + &[], + "wss://ws.example", + &Default::default(), + ); + + assert_eq!(hash_before, hash_after); +} + +#[test] +fn second_run_is_a_no_op_and_preserves_pristine_backup() { + // B5 gates: double-run idempotence + create-if-absent .bak. Run 1 + // migrates; run 2 must change nothing and must NOT clobber the pristine + // pre-migration backup with a half-migrated snapshot. + let dir = tempfile::tempdir().unwrap(); + let pubkey = "e".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([standalone_agent_json("Solo", &pubkey, Some("P"))]), + ); + let pristine = std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let after_first = + std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 0, + "second run is a no-op" + ); + let after_second = + std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + assert_eq!(after_first, after_second, "store untouched by re-run"); + + let bak = + std::fs::read_to_string(base(dir.path()).join("managed-agents.json.pre-backfill.bak")) + .unwrap(); + assert_eq!( + bak, pristine, + "backup is the PRE-migration state, never clobbered" + ); +} + +#[test] +fn definitions_and_linked_records_are_untouched() { + // Already-linked instances and existing definitions pass through + // byte-identical; a store with nothing to backfill takes no backup. + let dir = tempfile::tempdir().unwrap(); + let pubkey = "f".repeat(64); + let mut linked = standalone_agent_json("Linked", &pubkey, Some("P")); + linked["persona_id"] = serde_json::json!("some-definition"); + write_agents_json(dir.path(), &serde_json::json!([linked])); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 0 + ); + assert!( + !base(dir.path()) + .join("managed-agents.json.pre-backfill.bak") + .exists(), + "no work, no backup" + ); + let records = read_agents_json(dir.path()); + assert_eq!(records.len(), 1, "nothing manufactured"); +} + +#[test] +fn slug_collision_fails_loudly_per_record_and_continues() { + // A pre-existing definition improbably slugged as an agent's pubkey: + // that record is skipped (logged), the rest proceed. + let dir = tempfile::tempdir().unwrap(); + let colliding = "1".repeat(64); + let clean = "2".repeat(64); + let mut definition = standalone_agent_json("Def", "", Some("P")); + definition["slug"] = serde_json::json!(colliding.clone()); + definition["pubkey"] = serde_json::json!(""); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition, + standalone_agent_json("Collides", &colliding, Some("P")), + standalone_agent_json("Clean", &clean, Some("P")), + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1, + "collision skipped, clean record backfilled" + ); + let records = load_typed(dir.path()); + let collided = records.iter().find(|r| r.pubkey == colliding).unwrap(); + assert_eq!(collided.persona_id, None, "collided record left standalone"); + let clean_rec = records.iter().find(|r| r.pubkey == clean).unwrap(); + assert_eq!(clean_rec.persona_id.as_deref(), Some(clean.as_str())); +} diff --git a/desktop/src-tauri/src/migration/detach.rs b/desktop/src-tauri/src/migration/detach.rs new file mode 100644 index 0000000000..9f746e479f --- /dev/null +++ b/desktop/src-tauri/src/migration/detach.rs @@ -0,0 +1,160 @@ +//! T4 migration: lift pack-level instructions into `TeamRecord.instructions` +//! and detach all directory-backed teams from their file-layer plumbing. + +use std::collections::HashMap; +use std::path::Path; + +use crate::managed_agents::{ManagedAgentRecord, TeamRecord}; + +/// Lift pack instructions into `TeamRecord.instructions` and detach +/// directory-backed teams from their source directories. +/// +/// Runs on app launch if any `TeamRecord` still has `source_dir` set. +/// Both output files are written atomically (temp-file + rename), so a crash +/// mid-write leaves the previous version intact and the migration can safely +/// retry on next boot. +/// +/// Steps (written last so the idempotency gate stays open until both files +/// are committed): +/// +/// 1. Build a persona-key → team ID map from the directory-backed teams. +/// 2. Backfill `team_id` and clear `persona_team_dir`/`persona_name_in_team` +/// on every `ManagedAgentRecord`. +/// 3. Read `instructions.md` from each team's `source_dir`; populate +/// `instructions` if the field is not already set. +/// 4. Clear `source_dir`, `is_symlink`, `symlink_target`, `version` on each +/// directory-backed `TeamRecord`. +pub fn detach_directory_backed_teams(app: &tauri::AppHandle) { + let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { + return; + }; + match detach_directory_backed_teams_in_dir(&base_dir) { + Ok(0) => {} + Ok(n) => eprintln!("buzz-desktop: detach-dir-teams: detached {n} directory-backed team(s)"), + Err(e) => eprintln!("buzz-desktop: detach-dir-teams: {e}"), + } +} + +/// Core logic, decoupled from the Tauri `AppHandle` for testing. +/// +/// `base_dir` is the managed-agents base directory (`/agents/`). +/// Returns the number of teams detached (0 = nothing to do). +pub(super) fn detach_directory_backed_teams_in_dir(base_dir: &Path) -> Result { + let teams_path = base_dir.join("teams.json"); + let agents_path = base_dir.join("managed-agents.json"); + + if !teams_path.exists() { + return Ok(0); + } + + let teams_content = std::fs::read_to_string(&teams_path) + .map_err(|e| format!("failed to read teams.json: {e}"))?; + let mut teams: Vec = serde_json::from_str(&teams_content) + .map_err(|e| format!("failed to parse teams.json: {e}"))?; + + if !teams.iter().any(|t| t.source_dir.is_some()) { + return Ok(0); + } + + // Build persona-key → team ID map before clearing source_dirs. + // persona-key = the directory name (pack manifest ID) or, for teams without + // a source_dir, the team UUID. This matches the value stored in + // AgentDefinition.source_team / ManagedAgentRecord.source_team. + let persona_key_to_team_id: HashMap = teams + .iter() + .filter(|t| t.source_dir.is_some()) + .map(|t| { + let key = t + .source_dir + .as_deref() + .and_then(|d| d.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or(&t.id) + .to_string(); + (key, t.id.clone()) + }) + .collect(); + + // Step 1 (agents first so the idempotency gate on teams.json stays open + // until both files are successfully written): + if agents_path.exists() { + let agents_content = std::fs::read_to_string(&agents_path) + .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; + let mut agents: Vec = serde_json::from_str(&agents_content) + .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; + + let mut agents_changed = false; + for agent in agents.iter_mut() { + // Backfill team_id from source_team (absorbed from the definition, + // equals the pack directory name / persona key). + if agent.team_id.is_none() { + if let Some(source_team) = agent.source_team.as_deref() { + if let Some(team_id) = persona_key_to_team_id.get(source_team) { + agent.team_id = Some(team_id.clone()); + agents_changed = true; + } + } + } + // F1 (Thufir): clear instance-side pack plumbing. These fields + // drove BUZZ_ACP_PERSONA_PACK / BUZZ_ACP_PERSONA_NAME env vars + // at spawn, which T3 already removed. Clearing removes the dead + // data so T6 can safely delete the consuming code. + if agent.persona_team_dir.is_some() || agent.persona_name_in_team.is_some() { + agent.persona_team_dir = None; + agent.persona_name_in_team = None; + agents_changed = true; + } + } + + if agents_changed { + let payload = serde_json::to_vec_pretty(&agents) + .map_err(|e| format!("failed to serialize managed-agents.json: {e}"))?; + crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?; + } + } + + // Step 2: lift instructions + detach each directory-backed team. + // Written last — clearing source_dir closes the idempotency gate. + let now = chrono::Utc::now().to_rfc3339(); + let mut detached = 0usize; + for team in teams.iter_mut() { + let Some(source_dir) = team.source_dir.take() else { + continue; + }; + + // Lift instructions.md content if the field is not already populated. + // If reading fails, log and leave instructions as-is (never corrupt). + if team.instructions.is_none() { + let instructions_path = source_dir.join("instructions.md"); + if instructions_path.exists() { + match std::fs::read_to_string(&instructions_path) { + Ok(content) => { + let trimmed = content.trim().to_string(); + if !trimmed.is_empty() { + team.instructions = Some(trimmed); + } + } + Err(e) => eprintln!( + "buzz-desktop: detach-dir-teams: team {}: \ + failed to read instructions.md (preserving existing value): {e}", + team.id + ), + } + } + } + + // F3 (Thufir): clear all file-layer fields. The UI badges off + // isSymlink — leaving it set without a source_dir is inconsistent. + team.is_symlink = false; + team.symlink_target = None; + team.version = None; + team.updated_at = now.clone(); + detached += 1; + } + + let payload = serde_json::to_vec_pretty(&teams) + .map_err(|e| format!("failed to serialize teams.json: {e}"))?; + crate::managed_agents::atomic_write_json(&teams_path, &payload)?; + + Ok(detached) +} diff --git a/desktop/src-tauri/src/migration/fold.rs b/desktop/src-tauri/src/migration/fold.rs new file mode 100644 index 0000000000..727982e448 --- /dev/null +++ b/desktop/src-tauri/src/migration/fold.rs @@ -0,0 +1,316 @@ +//! Phase 1A.2 (unified agent model): one-way fold of `personas.json` into +//! the unified agent store as key-less definition records. + +use std::path::Path; + +/// Fold `personas.json` into the unified agent store (Phase 1A.2). +/// +/// One-way, versioned by presence: runs only while `personas.json` exists. +/// Each persona becomes a key-less definition record +/// ([`AgentDefinition::into_agent_record`]) appended to `managed-agents.json` +/// via the definition-preserving save; the old file is renamed to +/// `personas.json.bak` so a second boot is a no-op and the data survives for +/// manual recovery. Built-ins are skipped — `merge_personas` regenerates them +/// from code on every load, exactly as before. +/// +/// Ordering (see `run_boot_migrations`): runs after the JSON-level +/// `personas.json` migrations (which must see the legacy file) and BEFORE +/// every consumer of the `load/save_personas` shims — `sync_team_personas`, +/// `reconcile_provider_mcp_commands`, and `materialize_agent_runtimes` all +/// read definitions post-fold via [`load_persona_runtimes`]'s unified-store +/// branch. +pub fn fold_personas_into_agent_store(app: &tauri::AppHandle) { + let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { + return; + }; + match fold_personas_in_dir(&base_dir) { + Ok(None) => {} + Ok(Some(folded)) => { + eprintln!( + "buzz-desktop: persona-store-fold: {folded} definitions folded into the unified store" + ); + } + Err(e) => eprintln!("buzz-desktop: persona-store-fold: {e}"), + } +} + +/// Core fold logic, decoupled from the Tauri `AppHandle` for testing. +/// Operates on the raw JSON files — no keyring interaction: instance records +/// are passed through byte-identical, and folded definitions carry no keys. +/// Returns `Ok(None)` when there is no `personas.json` to fold. +fn fold_personas_in_dir(base_dir: &Path) -> Result, String> { + let personas_path = base_dir.join("personas.json"); + if !personas_path.exists() { + return Ok(None); + } + + let personas = crate::managed_agents::load_personas_from_path(&personas_path)?; + + let agents_path = base_dir.join("managed-agents.json"); + let mut all: Vec = if agents_path.exists() { + let content = std::fs::read_to_string(&agents_path) + .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; + serde_json::from_str(&content) + .map_err(|e| format!("failed to parse managed-agents.json: {e}"))? + } else { + Vec::new() + }; + + let existing: std::collections::HashSet = all + .iter() + .filter_map(|record| record.slug.clone()) + .collect(); + + let mut folded = 0usize; + for persona in personas { + // Built-ins regenerate from code; a slug already in the store means + // a previous partial fold got that far — never duplicate. + if persona.is_builtin || existing.contains(&persona.id) { + continue; + } + all.push(persona.into_agent_record()); + folded += 1; + } + + let payload = serde_json::to_vec_pretty(&all) + .map_err(|e| format!("failed to serialize unified store: {e}"))?; + crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?; + + // Rename only after the unified store write succeeded — a crash between + // the two leaves personas.json in place and the fold re-runs idempotently + // (slug dedup above). + std::fs::rename(&personas_path, base_dir.join("personas.json.bak")) + .map_err(|e| format!("failed to retire personas.json: {e}"))?; + Ok(Some(folded)) +} + +/// Build a `persona_id → runtime` map from the personas.json sibling of the +/// given managed-agents.json path. Returns an empty map when personas can't be +/// read or parsed — callers then fall back to the record's own snapshot. +pub(super) fn load_persona_runtimes( + agents_path: &Path, +) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + let Some(dir) = agents_path.parent() else { + return map; + }; + // Pre-fold boots read the legacy personas.json; post-fold boots read the + // key-less definitions sharing the agents store itself (slug + runtime). + let personas_path = dir.join("personas.json"); + if personas_path.exists() { + let Ok(content) = std::fs::read_to_string(&personas_path) else { + return map; + }; + let Ok(records) = serde_json::from_str::>(&content) else { + return map; + }; + for record in records { + if let (Some(id), Some(runtime)) = ( + record.get("id").and_then(|v| v.as_str()), + record.get("runtime").and_then(|v| v.as_str()), + ) { + map.insert(id.to_string(), runtime.to_string()); + } + } + return map; + } + + let Ok(content) = std::fs::read_to_string(agents_path) else { + return map; + }; + let Ok(records) = serde_json::from_str::>(&content) else { + return map; + }; + for record in records { + // Definition records: key-less (no/empty pubkey), slug-addressed. + let keyed = record + .get("pubkey") + .and_then(|v| v.as_str()) + .is_some_and(|p| !p.is_empty()); + if keyed { + continue; + } + if let (Some(slug), Some(runtime)) = ( + record.get("slug").and_then(|v| v.as_str()), + record.get("runtime").and_then(|v| v.as_str()), + ) { + map.insert(slug.to_string(), runtime.to_string()); + } + } + map +} + +#[cfg(test)] +mod tests { + use super::fold_personas_in_dir; + use crate::migration::load_persona_runtimes; + use crate::migration::test_support::{ + read_agents_json, write_agents_json, write_personas_json, + }; + + // ── Persona-store fold (Phase 1A.2) ────────────────────────────────────────── + + fn keyed_agent_json(name: &str, pubkey: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "pubkey": pubkey, + "relay_url": "ws://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "start_on_app_launch": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }) + } + + fn custom_persona_json(id: &str, runtime: &str) -> serde_json::Value { + serde_json::json!({ + "id": id, + "display_name": format!("Name {id}"), + "avatar_url": null, + "system_prompt": format!("Prompt {id}"), + "runtime": runtime, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z" + }) + } + + #[test] + fn fold_moves_custom_personas_and_retires_the_file() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([ + custom_persona_json("custom:one", "goose"), + { "id": "builtin:fizz", "display_name": "Fizz", "system_prompt": "P", + "is_builtin": true, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" } + ]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([keyed_agent_json("Keyed", &"k".repeat(64))]), + ); + + let base = dir.path().join("agents"); + let folded = fold_personas_in_dir(&base).unwrap(); + assert_eq!(folded, Some(1), "custom folds, builtin skipped"); + + let records = read_agents_json(dir.path()); + assert_eq!(records.len(), 2, "definition + preserved instance"); + let def = records + .iter() + .find(|r| r.get("slug").is_some()) + .expect("folded definition present"); + assert_eq!(def["slug"], "custom:one"); + assert_eq!(def["runtime"], "goose"); + // Key-less: pubkey serializes as the empty string (field not skipped). + assert_eq!(def["pubkey"], "", "definition must be key-less"); + let keyed = records.iter().find(|r| r["name"] == "Keyed").unwrap(); + assert_eq!(keyed["pubkey"].as_str().unwrap().len(), 64); + + assert!(!base.join("personas.json").exists(), "source retired"); + assert!(base.join("personas.json.bak").exists(), ".bak left behind"); + } + + #[test] + fn fold_is_idempotent_across_partial_runs() { + // Simulate a crash after the store write but before the rename: the + // definition is already in the unified store AND personas.json is back. + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([custom_persona_json("custom:one", "goose")]), + ); + let base = dir.path().join("agents"); + assert_eq!(fold_personas_in_dir(&base).unwrap(), Some(1)); + + // Crash simulation: restore personas.json from the .bak. + std::fs::copy(base.join("personas.json.bak"), base.join("personas.json")).unwrap(); + assert_eq!( + fold_personas_in_dir(&base).unwrap(), + Some(0), + "second run folds nothing (slug dedup)" + ); + let records = read_agents_json(dir.path()); + assert_eq!(records.len(), 1, "no duplicate definition"); + } + + #[test] + fn fold_absent_personas_file_is_noop() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([keyed_agent_json("Keyed", &"k".repeat(64))]), + ); + let base = dir.path().join("agents"); + let before = std::fs::read_to_string(base.join("managed-agents.json")).unwrap(); + assert_eq!(fold_personas_in_dir(&base).unwrap(), None); + let after = std::fs::read_to_string(base.join("managed-agents.json")).unwrap(); + assert_eq!(before, after, "store untouched when nothing to fold"); + } + + #[test] + fn post_fold_runtime_map_reads_unified_definitions() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([custom_persona_json("custom:one", "goose")]), + ); + let mut linked = keyed_agent_json("Keyed", &"k".repeat(64)); + linked["persona_id"] = serde_json::json!("custom:one"); + write_agents_json(dir.path(), &serde_json::json!([linked])); + let base = dir.path().join("agents"); + let agents_path = base.join("managed-agents.json"); + + // Pre-fold: map comes from personas.json. + let pre = load_persona_runtimes(&agents_path); + assert_eq!(pre.get("custom:one").map(String::as_str), Some("goose")); + + fold_personas_in_dir(&base).unwrap(); + + // Post-fold: personas.json is gone; map must come from the unified store + // and be identical. + let post = load_persona_runtimes(&agents_path); + assert_eq!(post.get("custom:one").map(String::as_str), Some("goose")); + } + + #[test] + fn fold_dedup_prefers_the_store_over_the_persona_file() { + // Crash-between-re-run case (Pinky, review): the definition already in + // the unified store WINS over a personas.json entry with the same slug — + // the store copy is the one the successful fold wrote, and a user may + // have edited it since; re-folding the stale file copy would clobber it. + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([custom_persona_json("custom:one", "goose")]), + ); + let base = dir.path().join("agents"); + assert_eq!(fold_personas_in_dir(&base).unwrap(), Some(1)); + + // Post-fold edit in the unified store. + let mut records = read_agents_json(dir.path()); + records[0]["runtime"] = serde_json::json!("claude"); + write_agents_json(dir.path(), &serde_json::Value::Array(records)); + + // Crash simulation: stale personas.json (runtime still "goose") returns. + std::fs::copy(base.join("personas.json.bak"), base.join("personas.json")).unwrap(); + assert_eq!(fold_personas_in_dir(&base).unwrap(), Some(0)); + + let records = read_agents_json(dir.path()); + assert_eq!(records.len(), 1); + assert_eq!( + records[0]["runtime"], "claude", + "store copy wins over the stale file copy" + ); + } +} diff --git a/desktop/src-tauri/src/migration/materialize.rs b/desktop/src-tauri/src/migration/materialize.rs new file mode 100644 index 0000000000..5930920dd2 --- /dev/null +++ b/desktop/src-tauri/src/migration/materialize.rs @@ -0,0 +1,140 @@ +//! Phase 1A (unified agent model): boot-time materialization of each +//! persona-linked agent record's `runtime` onto the record itself. +//! +//! Child module of `migration` so it reuses the parent's private JSON-patch +//! helpers (`patch_json_records`, `load_persona_runtimes`, +//! `canonical_dev_data_dir`). + +use std::path::Path; + +use tauri::Manager as _; + +use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records}; + +/// Materialize each persona-linked agent record's `runtime` from its linked +/// persona (unified agent model, Phase 1A). After this, spawn resolution reads +/// the record's own runtime (`record_agent_command` step 2) instead of the +/// live persona — same effective command by construction, so the spawn-config +/// hash is unchanged and no running agent shows a spurious restart badge (see +/// `spawn_hash::tests::materializing_runtime_keeps_hash_stable`). +/// +/// Idempotent: records that already carry `runtime` are untouched, as are +/// records with no linked persona or a persona without a runtime (both keep +/// resolving through the legacy fallback path unchanged). +pub fn materialize_agent_runtimes(app: &tauri::AppHandle) { + let Ok(current_dir) = app.path().app_data_dir() else { + return; + }; + let mut dirs = vec![current_dir.clone()]; + if let Some(canonical) = canonical_dev_data_dir(¤t_dir) { + if canonical.exists() && canonical != current_dir { + dirs.push(canonical); + } + } + for dir in dirs { + let path = dir.join("agents/managed-agents.json"); + if path.exists() { + materialize_runtimes_in_file(&path); + } + } +} + +fn materialize_runtimes_in_file(path: &Path) { + let persona_runtimes = load_persona_runtimes(path); + if persona_runtimes.is_empty() { + return; + } + patch_json_records(path, |obj| { + if obj.contains_key("runtime") { + return false; + } + let Some(runtime) = obj + .get("persona_id") + .and_then(|v| v.as_str()) + .and_then(|pid| persona_runtimes.get(pid)) + else { + return false; + }; + obj.insert( + "runtime".to_string(), + serde_json::Value::String(runtime.clone()), + ); + true + }); +} + +#[cfg(test)] +mod tests { + use super::materialize_runtimes_in_file; + use crate::migration::test_support::{ + read_agents_json, write_agents_json, write_personas_json, + }; + + #[test] + fn materialize_runtimes_copies_persona_runtime_onto_record() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "runtime": "goose" }]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([{ "name": "Fizz", "persona_id": "persona-1" }]), + ); + materialize_runtimes_in_file(&dir.path().join("agents/managed-agents.json")); + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["runtime"], "goose"); + } + + #[test] + fn materialize_runtimes_is_idempotent_and_preserves_existing() { + // A record that already carries runtime (even one diverging from its + // persona) is never overwritten, and the second run rewrites nothing. + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "runtime": "goose" }]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + { "name": "Fizz", "persona_id": "persona-1", "runtime": "claude" }, + { "name": "Buzz", "persona_id": "persona-1" } + ]), + ); + let agents_path = dir.path().join("agents/managed-agents.json"); + materialize_runtimes_in_file(&agents_path); + let records = read_agents_json(dir.path()); + assert_eq!( + records[0]["runtime"], "claude", + "existing runtime preserved" + ); + assert_eq!(records[1]["runtime"], "goose"); + + let before = std::fs::read_to_string(&agents_path).unwrap(); + materialize_runtimes_in_file(&agents_path); + let after = std::fs::read_to_string(&agents_path).unwrap(); + assert_eq!(before, after, "second run must be a no-op"); + } + + #[test] + fn materialize_runtimes_skips_unlinked_and_unknown_personas() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "runtime": "goose" }]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + { "name": "NoPersona" }, + { "name": "GonePersona", "persona_id": "deleted" } + ]), + ); + let agents_path = dir.path().join("agents/managed-agents.json"); + let before = std::fs::read_to_string(&agents_path).unwrap(); + materialize_runtimes_in_file(&agents_path); + let after = std::fs::read_to_string(&agents_path).unwrap(); + assert_eq!(before, after, "no linked runtime → untouched file"); + } +} diff --git a/desktop/src-tauri/src/migration_databricks_tests.rs b/desktop/src-tauri/src/migration_databricks_tests.rs new file mode 100644 index 0000000000..842507ec83 --- /dev/null +++ b/desktop/src-tauri/src/migration_databricks_tests.rs @@ -0,0 +1,360 @@ +use super::test_support::*; +use super::*; + +// ── reconcile_databricks_v1_to_v2_in_file ──────────────────────────────── + +#[test] +fn reconcile_databricks_v1_to_v2_rewrites_v1_provider_on_block_build() { + // rewrite_v1_provider=true simulates a Block build (baked env has + // BUZZ_AGENT_PROVIDER=databricks_v2). The structured provider field + // must be migrated V1→V2 and the stale V1 model field must be cleared + // so the baked DATABRICKS_MODEL wins at spawn time instead of the V1 name. + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Brain", + "provider": "databricks", + "model": "dbrx-instruct" + }]), + ); + + reconcile_databricks_v1_to_v2_in_file( + &dir.path().join("agents/managed-agents.json"), + /*rewrite_v1_provider=*/ true, + ); + + let records = read_agents_json(dir.path()); + assert_eq!( + records[0]["provider"], "databricks_v2", + "provider: \"databricks\" must be rewritten to \"databricks_v2\" on Block builds" + ); + // Stale V1 model must be cleared so the baked DATABRICKS_MODEL is not + // shadowed by BUZZ_AGENT_MODEL at spawn time (last-write-wins in Command::env). + assert!( + records[0].get("model").is_none_or(|v| v.is_null()), + "stale V1 model field must be cleared when provider is rewritten to V2" + ); +} + +#[test] +fn reconcile_databricks_v1_to_v2_preserves_v1_provider_on_oss_build() { + // rewrite_v1_provider=false simulates an OSS build (empty baked env). + // V1 ("databricks") is a valid Model Serving provider for OSS users; + // the structured provider field must NOT be rewritten. + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Brain", + "provider": "databricks", + "model": "dbrx-instruct", + "env_vars": { "BUZZ_AGENT_PROVIDER": "databricks" } + }]), + ); + + reconcile_databricks_v1_to_v2_in_file( + &dir.path().join("agents/managed-agents.json"), + /*rewrite_v1_provider=*/ false, + ); + + let records = read_agents_json(dir.path()); + // Provider field preserved. + assert_eq!( + records[0]["provider"], "databricks", + "provider field must not be rewritten on OSS builds" + ); + assert_eq!(records[0]["model"], "dbrx-instruct"); + // Stale env var is still stripped even on OSS builds. + assert!( + records[0]["env_vars"].get("BUZZ_AGENT_PROVIDER").is_none(), + "BUZZ_AGENT_PROVIDER must be stripped even when provider rewrite is disabled" + ); +} + +#[test] +fn reconcile_databricks_v1_to_v2_clears_model_on_provider_rewrite() { + // When a V1 record is migrated to V2 on a Block build, the model field + // must be removed. A stale V1 model name (e.g. "dbrx-instruct") emitted + // via BUZZ_AGENT_MODEL at spawn time would shadow the baked DATABRICKS_MODEL + // (last-write-wins), sending the agent to a V1 model on V2 endpoints. + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([ + { "name": "A", "provider": "databricks", "model": "dbrx-instruct" }, + { "name": "B", "provider": "databricks", "model": "goose-claude-opus-4-8-wrong" }, + // V2 record with model — model must NOT be cleared. + { "name": "C", "provider": "databricks_v2", "model": "goose-claude-4-8-opus" } + ]), + ); + + reconcile_databricks_v1_to_v2_in_file( + &dir.path().join("agents/managed-agents.json"), + /*rewrite_v1_provider=*/ true, + ); + + let records = read_agents_json(dir.path()); + // V1 records: provider migrated, model cleared. + assert_eq!(records[0]["provider"], "databricks_v2"); + assert!( + records[0].get("model").is_none_or(|v| v.is_null()), + "model must be cleared for V1→V2 migrated record A" + ); + assert_eq!(records[1]["provider"], "databricks_v2"); + assert!( + records[1].get("model").is_none_or(|v| v.is_null()), + "model must be cleared for V1→V2 migrated record B" + ); + // V2 record: model untouched. + assert_eq!(records[2]["provider"], "databricks_v2"); + assert_eq!( + records[2]["model"], "goose-claude-4-8-opus", + "model must not be cleared for already-V2 record C" + ); +} + +#[test] +fn reconcile_databricks_v1_to_v2_preserves_v2_provider() { + let dir = tempfile::tempdir().unwrap(); + let json = serde_json::json!([{ + "name": "Brain", + "provider": "databricks_v2", + "model": "goose-claude-4-6-sonnet" + }]); + write_agents_json(dir.path(), &json); + let path = dir.path().join("agents/managed-agents.json"); + let before = std::fs::read_to_string(&path).unwrap(); + + reconcile_databricks_v1_to_v2_in_file(&path, /*rewrite_v1_provider=*/ true); + + // File must be unchanged — no spurious re-write. + assert_eq!(before, std::fs::read_to_string(&path).unwrap()); +} + +#[test] +fn reconcile_databricks_v1_to_v2_strips_stale_buzz_agent_provider_from_env_vars() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Brain", + "provider": "databricks_v2", + "model": "goose-claude-4-6-sonnet", + "env_vars": { + "BUZZ_AGENT_PROVIDER": "databricks", + "DATABRICKS_HOST": "https://dbc.example.com" + } + }]), + ); + + reconcile_databricks_v1_to_v2_in_file( + &dir.path().join("agents/managed-agents.json"), + /*rewrite_v1_provider=*/ true, + ); + + let records = read_agents_json(dir.path()); + // Stale derived key must be removed. + assert!( + records[0]["env_vars"].get("BUZZ_AGENT_PROVIDER").is_none(), + "BUZZ_AGENT_PROVIDER must be stripped from env_vars" + ); + // Non-derived keys must be preserved. + assert_eq!( + records[0]["env_vars"]["DATABRICKS_HOST"], + "https://dbc.example.com" + ); +} + +#[test] +fn reconcile_databricks_v1_to_v2_strips_all_derived_keys_from_env_vars() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Brain", + "provider": "anthropic", + "model": "claude-opus-4-5", + "env_vars": { + "BUZZ_AGENT_PROVIDER": "anthropic", + "BUZZ_AGENT_MODEL": "claude-opus-4-5", + "GOOSE_PROVIDER": "anthropic", + "GOOSE_MODEL": "claude-opus-4-5", + "ANTHROPIC_API_KEY": "sk-test" + } + }]), + ); + + reconcile_databricks_v1_to_v2_in_file( + &dir.path().join("agents/managed-agents.json"), + /*rewrite_v1_provider=*/ true, + ); + + let records = read_agents_json(dir.path()); + let env_vars = &records[0]["env_vars"]; + // All four derived keys must be stripped. + assert!(env_vars.get("BUZZ_AGENT_PROVIDER").is_none()); + assert!(env_vars.get("BUZZ_AGENT_MODEL").is_none()); + assert!(env_vars.get("GOOSE_PROVIDER").is_none()); + assert!(env_vars.get("GOOSE_MODEL").is_none()); + // Non-derived key must be preserved. + assert_eq!(env_vars["ANTHROPIC_API_KEY"], "sk-test"); +} + +#[test] +fn reconcile_databricks_v1_to_v2_handles_multiple_records_block_build() { + // On Block builds (rewrite_v1_provider=true): V1 provider is migrated and + // env_vars stripping applies to every record. + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([ + { + "name": "Agent A", + "provider": "databricks", + "env_vars": { "BUZZ_AGENT_PROVIDER": "databricks" } + }, + { + "name": "Agent B", + "provider": "anthropic", + "env_vars": { "BUZZ_AGENT_MODEL": "claude-3-5-sonnet" } + }, + { + "name": "Agent C", + "provider": "databricks_v2", + "env_vars": {} + } + ]), + ); + + reconcile_databricks_v1_to_v2_in_file( + &dir.path().join("agents/managed-agents.json"), + /*rewrite_v1_provider=*/ true, + ); + + let records = read_agents_json(dir.path()); + // A: provider rewritten, stale env_var stripped. + assert_eq!(records[0]["provider"], "databricks_v2"); + assert!(records[0]["env_vars"].get("BUZZ_AGENT_PROVIDER").is_none()); + // B: provider untouched, stale BUZZ_AGENT_MODEL stripped. + assert_eq!(records[1]["provider"], "anthropic"); + assert!(records[1]["env_vars"].get("BUZZ_AGENT_MODEL").is_none()); + // C: V2 provider, no stale keys — unchanged. + assert_eq!(records[2]["provider"], "databricks_v2"); +} + +#[test] +fn reconcile_databricks_v1_to_v2_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Brain", + "provider": "databricks", + "env_vars": { "BUZZ_AGENT_PROVIDER": "databricks" } + }]), + ); + let path = dir.path().join("agents/managed-agents.json"); + + reconcile_databricks_v1_to_v2_in_file(&path, /*rewrite_v1_provider=*/ true); + let after_first = std::fs::read_to_string(&path).unwrap(); + reconcile_databricks_v1_to_v2_in_file(&path, /*rewrite_v1_provider=*/ true); + let after_second = std::fs::read_to_string(&path).unwrap(); + + assert_eq!( + after_first, after_second, + "second pass must not modify the file" + ); +} + +#[test] +fn reconcile_databricks_v1_to_v2_preserves_non_databricks_providers() { + let dir = tempfile::tempdir().unwrap(); + let json = serde_json::json!([ + { "name": "A", "provider": "anthropic" }, + { "name": "B", "provider": "openai" }, + { "name": "C", "provider": "openai-compat" }, + ]); + write_agents_json(dir.path(), &json); + let path = dir.path().join("agents/managed-agents.json"); + let before = std::fs::read_to_string(&path).unwrap(); + + reconcile_databricks_v1_to_v2_in_file(&path, /*rewrite_v1_provider=*/ true); + + // No provider is modified, so the file content is identical. + assert_eq!(before, std::fs::read_to_string(&path).unwrap()); +} + +#[test] +fn reconcile_databricks_v1_to_v2_strips_derived_keys_from_keyless_persona_definition() { + // Folded persona definitions land in managed-agents.json without a + // "provider" key (they are keyless/definition records). Stale derived env + // keys in their env_vars must be stripped by the migration just like + // full agent records — persona env is merged after runtime metadata and + // can shadow structured fields at spawn time. + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + // No "provider" or "model" key — this is a folded persona definition. + "name": "Fizz", + "persona_id": "builtin:fizz", + "env_vars": { + "BUZZ_AGENT_PROVIDER": "databricks", + "BUZZ_AGENT_MODEL": "goose-claude-4-6-sonnet", + "DATABRICKS_HOST": "https://dbc.example.com" + } + }]), + ); + + reconcile_databricks_v1_to_v2_in_file( + &dir.path().join("agents/managed-agents.json"), + /*rewrite_v1_provider=*/ true, + ); + + let records = read_agents_json(dir.path()); + let env_vars = &records[0]["env_vars"]; + // Derived keys stripped even though there is no top-level "provider" field. + assert!( + env_vars.get("BUZZ_AGENT_PROVIDER").is_none(), + "BUZZ_AGENT_PROVIDER must be stripped from keyless persona definition env_vars" + ); + assert!( + env_vars.get("BUZZ_AGENT_MODEL").is_none(), + "BUZZ_AGENT_MODEL must be stripped from keyless persona definition env_vars" + ); + // Non-derived key preserved. + assert_eq!(env_vars["DATABRICKS_HOST"], "https://dbc.example.com"); +} + +#[test] +fn reconcile_databricks_v1_to_v2_strips_derived_keys_case_insensitively() { + // The derived-key check is case-insensitive (matching is_derived_provider_model_key). + // A record with mixed-case variants must have those keys stripped. + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Brain", + "provider": "databricks_v2", + "env_vars": { + "buzz_agent_provider": "databricks", + "Buzz_Agent_Model": "goose-claude-4-6-sonnet", + "DATABRICKS_HOST": "https://dbc.example.com" + } + }]), + ); + + reconcile_databricks_v1_to_v2_in_file( + &dir.path().join("agents/managed-agents.json"), + /*rewrite_v1_provider=*/ true, + ); + + let records = read_agents_json(dir.path()); + let env_vars = &records[0]["env_vars"]; + // Mixed-case derived keys must be stripped. + assert!(env_vars.get("buzz_agent_provider").is_none()); + assert!(env_vars.get("Buzz_Agent_Model").is_none()); + // Non-derived key preserved. + assert_eq!(env_vars["DATABRICKS_HOST"], "https://dbc.example.com"); +} diff --git a/desktop/src-tauri/src/migration_sync_guard_tests.rs b/desktop/src-tauri/src/migration_sync_guard_tests.rs new file mode 100644 index 0000000000..46718d14a1 --- /dev/null +++ b/desktop/src-tauri/src/migration_sync_guard_tests.rs @@ -0,0 +1,27 @@ +use super::*; + +// ── is_dev_data_dir_name predicate ────────────────────────────────────────── + +#[test] +fn is_dev_data_dir_name_rejects_prod_identifier() { + assert!(!is_dev_data_dir_name("xyz.block.buzz.app")); +} + +#[test] +fn is_dev_data_dir_name_accepts_canonical_dev_identifier() { + assert!(is_dev_data_dir_name("xyz.block.buzz.app.dev")); +} + +#[test] +fn is_dev_data_dir_name_accepts_worktree_dev_identifier() { + assert!(is_dev_data_dir_name("xyz.block.buzz.app.dev.some-worktree")); +} + +/// Prefix-collision guard: an identifier that merely starts with the dev +/// prefix but is not dot-separated must be treated as prod, not dev. +/// `xyz.block.buzz.app.developer` is a hypothetical prod variant, not a +/// worktree of `xyz.block.buzz.app.dev`. +#[test] +fn is_dev_data_dir_name_rejects_prefix_collision() { + assert!(!is_dev_data_dir_name("xyz.block.buzz.app.developer")); +} diff --git a/desktop/src-tauri/src/migration_team_dir_tests.rs b/desktop/src-tauri/src/migration_team_dir_tests.rs index 1bad6a9e32..bc2ffc8f9f 100644 --- a/desktop/src-tauri/src/migration_team_dir_tests.rs +++ b/desktop/src-tauri/src/migration_team_dir_tests.rs @@ -1,435 +1,329 @@ use super::test_support::*; -use super::*; -// ── reconcile_team_dirs_in_file tests ──────────────────────────────── +// ── detach_directory_backed_teams_in_dir tests ────────────────────────── -#[test] -fn team_dir_reconcile_rewrites_worktree_path() { - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(canonical.join("agents")).unwrap(); - // Team must exist on disk for the reconcile to proceed past the existence gate. - std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap(); +fn write_teams_json(base: &std::path::Path, records: &serde_json::Value) { + std::fs::create_dir_all(base.join("agents")).unwrap(); + std::fs::write( + base.join("agents/teams.json"), + serde_json::to_vec_pretty(records).unwrap(), + ) + .unwrap(); +} - let worktree_pack_path = pack_dir( - &parent - .path() - .join("xyz.block.buzz.app.dev.worktree-my-branch"), - "com.wpfleger.sietch-tabr", - ); - let expected_path = team_dir(&canonical, "com.wpfleger.sietch-tabr"); - - write_agents_json( - &canonical, - &serde_json::json!([{ - "name": "Paul", - "persona_pack_path": worktree_pack_path - }]), - ); - - reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical); - - let records = read_agents_json(&canonical); - assert_eq!(records[0]["persona_team_dir"], expected_path); - // Old field name should be removed - assert!(records[0].get("persona_pack_path").is_none()); +fn read_teams_json(base: &std::path::Path) -> Vec { + let content = std::fs::read_to_string(base.join("agents/teams.json")).unwrap(); + serde_json::from_str(&content).unwrap() } #[test] -fn team_dir_reconcile_rewrites_new_field_name() { - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(canonical.join("agents")).unwrap(); - // Team must exist on disk for the reconcile to proceed past the existence gate. - std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap(); +fn detach_lifts_instructions_from_instructions_md() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); - let worktree_team_path = team_dir( - &parent - .path() - .join("xyz.block.buzz.app.dev.worktree-my-branch"), - "com.wpfleger.sietch-tabr", - ); - let expected_path = team_dir(&canonical, "com.wpfleger.sietch-tabr"); + // Create a team directory with instructions.md + let team_dir = base.join("teams/com.example.myteam"); + std::fs::create_dir_all(&team_dir).unwrap(); + std::fs::write(team_dir.join("instructions.md"), "Always be helpful.").unwrap(); - write_agents_json( - &canonical, + // Write teams.json with a directory-backed team + write_teams_json( + base, &serde_json::json!([{ - "name": "Paul", - "persona_team_dir": worktree_team_path + "id": "team-uuid-1", + "name": "My Team", + "persona_ids": [], + "is_builtin": false, + "source_dir": team_dir.to_str().unwrap(), + "is_symlink": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" }]), ); - reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical); + let n = super::detach::detach_directory_backed_teams_in_dir(&base.join("agents")).unwrap(); + assert_eq!(n, 1); - let records = read_agents_json(&canonical); - assert_eq!(records[0]["persona_team_dir"], expected_path); + let teams = read_teams_json(base); + assert_eq!(teams[0]["instructions"], "Always be helpful."); + assert!(teams[0].get("source_dir").is_none_or(|v| v.is_null())); + assert_eq!(teams[0]["is_symlink"], false); } #[test] -fn team_dir_reconcile_leaves_canonical_path_unchanged() { - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(canonical.join("agents")).unwrap(); +fn detach_skips_lift_if_instructions_already_set() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); - let canonical_path = team_dir(&canonical, "com.wpfleger.sietch-tabr"); + let team_dir = base.join("teams/com.example.myteam"); + std::fs::create_dir_all(&team_dir).unwrap(); + std::fs::write(team_dir.join("instructions.md"), "From disk.").unwrap(); - write_agents_json( - &canonical, + write_teams_json( + base, &serde_json::json!([{ - "name": "Duncan", - "persona_team_dir": canonical_path + "id": "team-uuid-1", + "name": "My Team", + "instructions": "Already set.", + "persona_ids": [], + "is_builtin": false, + "source_dir": team_dir.to_str().unwrap(), + "is_symlink": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" }]), ); - let before = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap(); - reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical); - let after = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap(); + super::detach::detach_directory_backed_teams_in_dir(&base.join("agents")).unwrap(); - assert_eq!(before, after); + let teams = read_teams_json(base); + // Should keep the pre-existing instructions, not overwrite with disk content + assert_eq!(teams[0]["instructions"], "Already set."); } #[test] -fn team_dir_reconcile_skips_records_without_team_dir() { - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(canonical.join("agents")).unwrap(); +fn detach_clears_is_symlink_and_version() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); - write_agents_json( - &canonical, + let team_dir = base.join("teams/com.example.myteam"); + std::fs::create_dir_all(&team_dir).unwrap(); + + write_teams_json( + base, &serde_json::json!([{ - "name": "Test Agent", - "agent_command": "buzz-agent" + "id": "team-uuid-1", + "name": "My Team", + "persona_ids": [], + "is_builtin": false, + "source_dir": team_dir.to_str().unwrap(), + "is_symlink": true, + "symlink_target": "/some/external/path", + "version": "1.2.3", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" }]), ); - let before = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap(); - reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical); - let after = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap(); + super::detach::detach_directory_backed_teams_in_dir(&base.join("agents")).unwrap(); - assert_eq!(before, after); + let teams = read_teams_json(base); + assert_eq!(teams[0]["is_symlink"], false); + assert!(teams[0].get("symlink_target").is_none_or(|v| v.is_null())); + assert!(teams[0].get("version").is_none_or(|v| v.is_null())); + assert!(teams[0].get("source_dir").is_none_or(|v| v.is_null())); } #[test] -fn team_dir_reconcile_is_idempotent() { - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(canonical.join("agents")).unwrap(); - // Team must exist on disk for the reconcile to proceed past the existence gate. - std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap(); +fn detach_backfills_team_id_on_agents() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); - let worktree_pack_path = pack_dir( - &parent - .path() - .join("xyz.block.buzz.app.dev.worktree-my-branch"), - "com.wpfleger.sietch-tabr", - ); + let team_dir = base.join("teams/com.example.myteam"); + std::fs::create_dir_all(&team_dir).unwrap(); - write_agents_json( - &canonical, + write_teams_json( + base, &serde_json::json!([{ - "name": "Paul", - "persona_pack_path": worktree_pack_path + "id": "team-uuid-1", + "name": "My Team", + "persona_ids": ["persona-1"], + "is_builtin": false, + "source_dir": team_dir.to_str().unwrap(), + "is_symlink": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" }]), ); - let path = canonical.join("agents/managed-agents.json"); - reconcile_team_dirs_in_file(&path, &canonical); - let after_first = std::fs::read_to_string(&path).unwrap(); - reconcile_team_dirs_in_file(&path, &canonical); - let after_second = std::fs::read_to_string(&path).unwrap(); + // Agent with source_team = directory name, no team_id, has persona_team_dir + write_agents_json( + base, + &serde_json::json!([{ + "pubkey": "aaaa", + "name": "agent-1", + "persona_id": "persona-1", + "source_team": "com.example.myteam", + "persona_team_dir": team_dir.to_str().unwrap(), + "persona_name_in_team": "agent-slug", + "relay_url": "wss://relay.example.com", + "acp_command": "claude", + "agent_command": "claude", + "mcp_command": "claude", + "agent_args": [], + "turn_timeout_seconds": 60, + "parallelism": 1, + "start_on_app_launch": false, + "auto_restart_on_config_change": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }]), + ); - assert_eq!(after_first, after_second); + super::detach::detach_directory_backed_teams_in_dir(&base.join("agents")).unwrap(); + + let agents = read_agents_json(base); + assert_eq!(agents[0]["team_id"], "team-uuid-1"); + assert!(agents[0] + .get("persona_team_dir") + .is_none_or(|v| v.is_null())); + assert!(agents[0] + .get("persona_name_in_team") + .is_none_or(|v| v.is_null())); } #[test] -fn team_dir_reconcile_heals_legacy_release_path() { - // Record stored under the old bundle id and old packs segment should be - // rewritten to the new bundle id and teams segment. - let parent = tempfile::tempdir().unwrap(); - let release_dir = parent.path().join("xyz.block.buzz.app"); - std::fs::create_dir_all(release_dir.join("agents/teams/com.example.team")).unwrap(); +fn detach_is_idempotent_on_second_run() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); - let legacy_path = pack_dir( - &parent.path().join("xyz.block.sprout.app"), - "com.example.team", - ); - let expected_path = team_dir(&release_dir, "com.example.team"); + let team_dir = base.join("teams/com.example.myteam"); + std::fs::create_dir_all(&team_dir).unwrap(); + std::fs::write(team_dir.join("instructions.md"), "Team rules.").unwrap(); - write_agents_json( - &release_dir, + write_teams_json( + base, &serde_json::json!([{ - "name": "Stilgar", - "persona_team_dir": legacy_path + "id": "team-uuid-1", + "name": "My Team", + "persona_ids": [], + "is_builtin": false, + "source_dir": team_dir.to_str().unwrap(), + "is_symlink": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" }]), ); - reconcile_team_dirs_in_file( - &release_dir.join("agents/managed-agents.json"), - &release_dir, - ); + // First run + let n1 = super::detach::detach_directory_backed_teams_in_dir(&base.join("agents")).unwrap(); + assert_eq!(n1, 1); - let records = read_agents_json(&release_dir); - assert_eq!(records[0]["persona_team_dir"], expected_path); + // Second run — should be a no-op + let n2 = super::detach::detach_directory_backed_teams_in_dir(&base.join("agents")).unwrap(); + assert_eq!(n2, 0); + + // Instructions should still be set from first run + let teams = read_teams_json(base); + assert_eq!(teams[0]["instructions"], "Team rules."); } #[test] -fn team_dir_reconcile_leaves_record_when_team_missing() { - // When the expected target does not exist, the record must be left untouched - // and the file must not be rewritten at all (no churn). - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(canonical.join("agents")).unwrap(); - // Intentionally do NOT create agents/teams/com.example.missing. +fn detach_skips_non_directory_backed_teams() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); - let stale_path = pack_dir( - &parent.path().join("xyz.block.buzz.app.dev.worktree-old"), - "com.example.missing", - ); - - write_agents_json( - &canonical, + write_teams_json( + base, &serde_json::json!([{ - "name": "Jessica", - "persona_pack_path": stale_path + "id": "team-uuid-1", + "name": "Pure JSON Team", + "instructions": "Existing instructions.", + "persona_ids": [], + "is_builtin": false, + "is_symlink": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" }]), ); - let path = canonical.join("agents/managed-agents.json"); - let before = std::fs::read_to_string(&path).unwrap(); - reconcile_team_dirs_in_file(&path, &canonical); - let after = std::fs::read_to_string(&path).unwrap(); + let n = super::detach::detach_directory_backed_teams_in_dir(&base.join("agents")).unwrap(); + assert_eq!(n, 0); - // File must be byte-identical — no churn. - assert_eq!(before, after); - // Record still has the old field and value. - let records = read_agents_json(&canonical); - assert_eq!(records[0]["persona_pack_path"], stale_path); - assert!(records[0].get("persona_team_dir").is_none()); + // File should not have been modified + let teams = read_teams_json(base); + assert_eq!(teams[0]["instructions"], "Existing instructions."); } #[cfg(unix)] #[test] -fn team_dir_reconcile_heals_to_symlinked_team_dir() { - // When agents/teams/ is a symlink to a real directory elsewhere, - // Path::exists follows the symlink, so the rewrite should proceed. - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(canonical.join("agents")).unwrap(); +fn detach_retries_after_teams_write_failure() { + // Write-seam interruption: make the agents/ directory read-only so the + // teams.json atomic write fails mid-detach. The agents.json write uses + // atomic_write_json_restricted (tmp+rename in the same dir), which also + // fails under a read-only parent. On retry (directory made writable), + // the still-open source_dir gate lets the migration complete. + use std::os::unix::fs::PermissionsExt; - // Real team lives outside the canonical dir; a symlink points to it. - let real_team = parent.path().join("real-team-store/com.example.team"); - std::fs::create_dir_all(&real_team).unwrap(); - let symlink_target = canonical.join("agents/teams/com.example.team"); - std::fs::create_dir_all(canonical.join("agents/teams")).unwrap(); - std::os::unix::fs::symlink(&real_team, &symlink_target).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); - let stale_path = pack_dir( - &parent.path().join("xyz.block.buzz.app.dev.worktree-old"), - "com.example.team", - ); - let expected_path = team_dir(&canonical, "com.example.team"); + let team_dir = base.join("teams/com.example.myteam"); + std::fs::create_dir_all(&team_dir).unwrap(); + std::fs::write(team_dir.join("instructions.md"), "Team rules.").unwrap(); - write_agents_json( - &canonical, + write_teams_json( + base, &serde_json::json!([{ - "name": "Chani", - "persona_pack_path": stale_path + "id": "team-uuid-1", + "name": "My Team", + "persona_ids": ["persona-1"], + "is_builtin": false, + "source_dir": team_dir.to_str().unwrap(), + "is_symlink": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }]), + ); + write_agents_json( + base, + &serde_json::json!([{ + "pubkey": "aaaa", + "name": "agent-1", + "persona_id": "persona-1", + "source_team": "com.example.myteam", + "persona_team_dir": team_dir.to_str().unwrap(), + "persona_name_in_team": "agent-slug", + "relay_url": "wss://relay.example.com", + "acp_command": "claude", + "agent_command": "claude", + "mcp_command": "claude", + "agent_args": [], + "turn_timeout_seconds": 60, + "parallelism": 1, + "start_on_app_launch": false, + "auto_restart_on_config_change": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" }]), ); - reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical); + // Make agents/ read-only so the atomic write (tmp file creation) fails. + let agents_dir = base.join("agents"); + std::fs::set_permissions(&agents_dir, std::fs::Permissions::from_mode(0o555)).unwrap(); - let records = read_agents_json(&canonical); - assert_eq!(records[0]["persona_team_dir"], expected_path); -} - -#[cfg(unix)] -#[test] -fn team_dir_reconcile_skips_dangling_candidate_symlink() { - // When agents/teams/ is a symlink whose target does not exist, - // Path::exists returns false, so the record must be left unchanged. - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(canonical.join("agents/teams")).unwrap(); - - // Create a dangling symlink at the expected location. - let dangling_target = parent.path().join("nonexistent-dir"); - let symlink_path = canonical.join("agents/teams/com.example.gone"); - std::os::unix::fs::symlink(&dangling_target, &symlink_path).unwrap(); - - let stale_path = pack_dir( - &parent.path().join("xyz.block.buzz.app.dev.worktree-old"), - "com.example.gone", + // Boot 1: detach fails because neither file can be written. + let result = super::detach::detach_directory_backed_teams_in_dir(&agents_dir); + assert!( + result.is_err(), + "detach must fail when directory is read-only" ); - write_agents_json( - &canonical, - &serde_json::json!([{ - "name": "Gurney", - "persona_pack_path": stale_path - }]), + // source_dir gate is still open — teams.json is unchanged. + // Restore permissions to read the file. + std::fs::set_permissions(&agents_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let teams_after_fail = read_teams_json(base); + assert!( + teams_after_fail[0] + .get("source_dir") + .is_some_and(|v| !v.is_null()), + "source_dir must remain set after failed detach" ); - let path = canonical.join("agents/managed-agents.json"); - let before = std::fs::read_to_string(&path).unwrap(); - reconcile_team_dirs_in_file(&path, &canonical); - let after = std::fs::read_to_string(&path).unwrap(); + // Boot 2: retry with writable directory — should succeed. + let n2 = super::detach::detach_directory_backed_teams_in_dir(&agents_dir).unwrap(); + assert_eq!(n2, 1, "retry detach must succeed"); - assert_eq!(before, after); - let records = read_agents_json(&canonical); - assert_eq!(records[0]["persona_pack_path"], stale_path); -} - -#[cfg(unix)] -#[test] -fn team_dir_reconcile_through_symlink_preserves_symlink() { - // Dev worktree instances reach the canonical store through a symlinked - // managed-agents.json — the patched write must land in the canonical - // file and leave the symlink in place. - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - let worktree = parent - .path() - .join("xyz.block.buzz.app.dev.worktree-my-branch"); - std::fs::create_dir_all(canonical.join("agents/teams/com.example.team")).unwrap(); - std::fs::create_dir_all(worktree.join("agents")).unwrap(); - - let stale_path = pack_dir(&worktree, "com.example.team"); - let expected_path = team_dir(&canonical, "com.example.team"); - - write_agents_json( - &canonical, - &serde_json::json!([{ - "name": "Paul", - "persona_pack_path": stale_path - }]), + let teams_after_retry = read_teams_json(base); + assert!( + teams_after_retry[0] + .get("source_dir") + .is_none_or(|v| v.is_null()), + "source_dir cleared after successful retry" ); - let canonical_store = canonical.join("agents/managed-agents.json"); - let worktree_store = worktree.join("agents/managed-agents.json"); - std::os::unix::fs::symlink(&canonical_store, &worktree_store).unwrap(); - - reconcile_team_dirs_in_file(&worktree_store, &canonical); - - assert!(worktree_store.is_symlink()); - let records = read_agents_json(&canonical); - assert_eq!(records[0]["persona_team_dir"], expected_path); -} - -// ── reconcile_target_dir tests ─────────────────────────────────────── - -#[test] -fn reconcile_target_dir_release_with_existing_dev_sibling_returns_self() { - // Release build must reconcile its OWN dir, even when a dev sibling exists. - let parent = tempfile::tempdir().unwrap(); - let release_dir = parent.path().join("xyz.block.buzz.app"); - let dev_dir = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(&release_dir).unwrap(); - std::fs::create_dir_all(&dev_dir).unwrap(); - - assert_eq!(reconcile_target_dir(&release_dir), release_dir); -} - -#[test] -fn reconcile_target_dir_worktree_dev_with_canonical_sibling_returns_canonical() { - // A worktree dev instance defers to the canonical dev dir when it exists. - let parent = tempfile::tempdir().unwrap(); - let worktree_dir = parent.path().join("xyz.block.buzz.app.dev.mybranch"); - let canonical_dir = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(&worktree_dir).unwrap(); - std::fs::create_dir_all(&canonical_dir).unwrap(); - - assert_eq!(reconcile_target_dir(&worktree_dir), canonical_dir); -} - -#[test] -fn reconcile_target_dir_canonical_dev_returns_self() { - // The canonical dev dir is itself a dev instance; it should return itself - // (canonical_dev_data_dir points at the same path, and that path exists). - let parent = tempfile::tempdir().unwrap(); - let canonical_dir = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(&canonical_dir).unwrap(); - - assert_eq!(reconcile_target_dir(&canonical_dir), canonical_dir); -} - -#[test] -fn reconcile_target_dir_release_without_dev_sibling_returns_self() { - // Release build with no dev sibling present — stays on its own dir. - let parent = tempfile::tempdir().unwrap(); - let release_dir = parent.path().join("xyz.block.buzz.app"); - std::fs::create_dir_all(&release_dir).unwrap(); - - assert_eq!(reconcile_target_dir(&release_dir), release_dir); -} - -#[test] -fn reconcile_target_dir_worktree_dev_without_canonical_sibling_returns_self() { - // Worktree dev instance with no canonical sibling present — stays on itself. - let parent = tempfile::tempdir().unwrap(); - let worktree_dir = parent.path().join("xyz.block.buzz.app.dev.mybranch"); - std::fs::create_dir_all(&worktree_dir).unwrap(); - - assert_eq!(reconcile_target_dir(&worktree_dir), worktree_dir); -} - -#[test] -fn reconcile_target_dir_ignores_non_prefix_dev_identifier() { - // Dev detection is prefix-only — a dir merely *containing* the dev - // identifier later in its name is not a dev instance. - let parent = tempfile::tempdir().unwrap(); - let odd_dir = parent.path().join("com.example.xyz.block.buzz.app.dev"); - let canonical_dir = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(&odd_dir).unwrap(); - std::fs::create_dir_all(&canonical_dir).unwrap(); - - assert_eq!(reconcile_target_dir(&odd_dir), odd_dir); -} - -#[test] -fn team_dir_reconcile_skips_path_without_teams_or_packs_component() { - // A path with no teams/packs component yields no team id — record is - // silently skipped and the file is not rewritten. - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(canonical.join("agents")).unwrap(); - - write_agents_json( - &canonical, - &serde_json::json!([{ - "name": "Leto", - "persona_team_dir": "/some/random/path" - }]), + assert_eq!( + teams_after_retry[0]["instructions"], "Team rules.", + "instructions lifted on retry" ); - - let path = canonical.join("agents/managed-agents.json"); - let before = std::fs::read_to_string(&path).unwrap(); - reconcile_team_dirs_in_file(&path, &canonical); - let after = std::fs::read_to_string(&path).unwrap(); - - assert_eq!(before, after); -} - -#[test] -fn team_dir_reconcile_renames_legacy_field_when_value_already_canonical() { - // Value already points at the target — only the legacy field name is - // normalized; the value is untouched. - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - std::fs::create_dir_all(canonical.join("agents/teams/com.example.team")).unwrap(); - - let correct_path = team_dir(&canonical, "com.example.team"); - - write_agents_json( - &canonical, - &serde_json::json!([{ - "name": "Thufir", - "persona_pack_path": correct_path - }]), - ); - - reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical); - - let records = read_agents_json(&canonical); - assert_eq!(records[0]["persona_team_dir"], correct_path); - assert!(records[0].get("persona_pack_path").is_none()); } diff --git a/desktop/src-tauri/src/migration_test_support.rs b/desktop/src-tauri/src/migration_test_support.rs index 399cb9c8f7..64a428949b 100644 --- a/desktop/src-tauri/src/migration_test_support.rs +++ b/desktop/src-tauri/src/migration_test_support.rs @@ -2,27 +2,6 @@ use std::path::Path; -/// Build the native-separator `/agents/teams/` string the way -/// production does (per-component `Path::join`), so test expectations match -/// reconcile output on Windows as well as Unix. -pub(crate) fn team_dir(base: &Path, id: &str) -> String { - base.join("agents") - .join("teams") - .join(id) - .display() - .to_string() -} - -/// Native-separator `/agents/packs/` — the pre-migration layout used -/// as reconcile input. See [`team_dir`] for why per-component join matters. -pub(crate) fn pack_dir(base: &Path, id: &str) -> String { - base.join("agents") - .join("packs") - .join(id) - .display() - .to_string() -} - pub(crate) fn write_agents_json(dir: &Path, records: &serde_json::Value) { std::fs::create_dir_all(dir.join("agents")).unwrap(); std::fs::write( diff --git a/desktop/src-tauri/src/migration_tests.rs b/desktop/src-tauri/src/migration_tests.rs index 8713b410db..0d49bd02aa 100644 --- a/desktop/src-tauri/src/migration_tests.rs +++ b/desktop/src-tauri/src/migration_tests.rs @@ -521,139 +521,6 @@ fn sync_replaces_real_teams_dir_with_symlink() { ); } -// ── Packs → Teams migration tests ─────────────────────────────────── - -#[cfg(unix)] -#[test] -fn migrate_packs_merge_preserves_non_empty_dir() { - // When packs/ contains symlinks that weren't moved (e.g., external tools - // recreated them), the migration should NOT delete the packs/ directory. - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - let packs_dir = canonical.join("agents/packs"); - let teams_dir = canonical.join("agents/teams"); - std::fs::create_dir_all(&packs_dir).unwrap(); - std::fs::create_dir_all(&teams_dir).unwrap(); - - // Simulate an external symlink that already exists in teams/ (conflict) - let external_target = parent.path().join("external-pack"); - std::fs::create_dir_all(&external_target).unwrap(); - std::os::unix::fs::symlink(&external_target, packs_dir.join("com.ext.pack")).unwrap(); - // Same name already in teams/ — so the migration skips it - std::os::unix::fs::symlink(&external_target, teams_dir.join("com.ext.pack")).unwrap(); - - // Run the merge logic (mirrors what migrate_packs_to_teams does) - if let Ok(entries) = std::fs::read_dir(&packs_dir) { - for entry in entries.flatten() { - let dest = teams_dir.join(entry.file_name()); - if !dest.exists() { - let _ = std::fs::rename(entry.path(), &dest); - } - } - } - // This is the fix: remove_dir only succeeds on empty dirs - let _ = std::fs::remove_dir(&packs_dir); - - // packs/ should still exist because it has a remaining symlink - assert!(packs_dir.exists(), "packs/ should survive when non-empty"); - assert!(packs_dir.join("com.ext.pack").is_symlink()); -} - -#[test] -fn migrate_packs_to_teams_renames_directory() { - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - let packs_dir = canonical.join("agents/packs/com.example.test-pack"); - std::fs::create_dir_all(&packs_dir).unwrap(); - std::fs::write(packs_dir.join("plugin.json"), "{}").unwrap(); - - // No personas or agents JSON needed for directory rename - std::fs::create_dir_all(canonical.join("agents")).unwrap(); - - // Simulate calling the migration steps directly (no AppHandle needed) - let packs = canonical.join("agents/packs"); - let teams = canonical.join("agents/teams"); - std::fs::rename(&packs, &teams).unwrap(); - - assert!(!packs.exists()); - assert!(teams.join("com.example.test-pack/plugin.json").exists()); -} - -#[test] -fn migrate_packs_to_teams_rewrites_personas_json() { - let dir = tempfile::tempdir().unwrap(); - write_personas_json( - dir.path(), - &serde_json::json!([{ - "id": "persona-1", - "display_name": "Test", - "source_pack": "com.example.my-pack", - "source_pack_persona_slug": "agent-one" - }]), - ); - - let path = dir.path().join("agents/personas.json"); - patch_json_records(&path, |obj| { - let mut changed = false; - if let Some(val) = obj.remove("source_pack") { - obj.insert("source_team".to_string(), val); - changed = true; - } - if let Some(val) = obj.remove("source_pack_persona_slug") { - obj.insert("source_team_persona_slug".to_string(), val); - changed = true; - } - changed - }); - - let records = read_personas_json(dir.path()); - assert_eq!(records[0]["source_team"], "com.example.my-pack"); - assert_eq!(records[0]["source_team_persona_slug"], "agent-one"); - assert!(records[0].get("source_pack").is_none()); - assert!(records[0].get("source_pack_persona_slug").is_none()); -} - -#[test] -fn migrate_packs_to_teams_rewrites_agents_json() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Paul", - "persona_pack_path": "/data/agents/packs/com.example.my-pack", - "persona_name_in_pack": "agent-one" - }]), - ); - - let path = dir.path().join("agents/managed-agents.json"); - patch_json_records(&path, |obj| { - let mut changed = false; - if let Some(val) = obj.remove("persona_pack_path") { - let new_val = if let Some(s) = val.as_str() { - serde_json::Value::String(s.replace("/packs/", "/teams/")) - } else { - val - }; - obj.insert("persona_team_dir".to_string(), new_val); - changed = true; - } - if let Some(val) = obj.remove("persona_name_in_pack") { - obj.insert("persona_name_in_team".to_string(), val); - changed = true; - } - changed - }); - - let records = read_agents_json(dir.path()); - assert_eq!( - records[0]["persona_team_dir"], - "/data/agents/teams/com.example.my-pack" - ); - assert_eq!(records[0]["persona_name_in_team"], "agent-one"); - assert!(records[0].get("persona_pack_path").is_none()); - assert!(records[0].get("persona_name_in_pack").is_none()); -} - /// `patch_json_records` rewrites `managed-agents.json`, which carries plaintext /// agent nsecs on a keyringless host — the writeback must land `0o600` from the /// write itself (no post-write `chmod`), or a launch-time reconcile reopens the @@ -1061,6 +928,23 @@ fn migrate_legacy_nest_noops_when_legacy_absent() { ); } +#[test] +fn migrate_legacy_nest_respects_deliberate_dev_reset() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz-dev"); + + std::fs::create_dir_all(legacy.join("RESEARCH")).unwrap(); + std::fs::write(legacy.join("RESEARCH/NOTES.md"), "legacy-notes").unwrap(); + std::fs::create_dir_all(¤t).unwrap(); + std::fs::write(current.join(".dev-nest-migrated"), "").unwrap(); + + let migrated = super::migrate_legacy_nest_at(&legacy, ¤t); + + assert!(!migrated, "reset marker opts out of legacy nest imports"); + assert!(!current.join("RESEARCH").exists()); +} + #[test] fn migrate_legacy_nest_overwrites_generated_default_agents_md() { let dir = tempfile::tempdir().unwrap(); @@ -1107,303 +991,3 @@ fn migrate_legacy_nest_preserves_user_edited_agents_md() { "a user-edited live AGENTS.md must never be clobbered" ); } - -/// Helper: write a `personas.json` directly in `base_dir` (the migration -/// reads `base_dir/personas.json`, where `base_dir` is the `agents` dir). -fn write_base_personas(base_dir: &Path, records: &serde_json::Value) { - std::fs::write( - base_dir.join("personas.json"), - serde_json::to_string_pretty(records).unwrap(), - ) - .unwrap(); -} - -fn one_persona() -> serde_json::Value { - serde_json::json!([{ - "id": "code-reviewer", - "display_name": "Code Reviewer", - "system_prompt": "You review code.", - "is_builtin": false, - "is_active": true, - "name_pool": [], - "env_vars": {}, - "created_at": "2025-01-01T00:00:00Z", - "updated_at": "2025-01-01T00:00:00Z" - }]) -} - -#[test] -fn migrate_personas_writes_signed_retention_rows() { - use crate::managed_agents::retention::{get_retained_personas, open_retention_db}; - - let base = tempfile::tempdir().unwrap(); - write_base_personas(base.path(), &one_persona()); - let keys = nostr::Keys::generate(); - let pubkey = keys.public_key().to_hex(); - - let migrated = migrate_personas_in_dir(base.path(), &keys).unwrap(); - assert_eq!(migrated, 1); - - let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); - let rows = get_retained_personas(&conn, &pubkey).unwrap(); - assert_eq!(rows.len(), 1); - // Row holds a real signed event for the owner — not a placeholder. - assert_eq!(rows[0].pubkey, pubkey); - let event: nostr::Event = nostr::JsonUtil::from_json(&rows[0].raw_event).unwrap(); - assert!(event.verify().is_ok()); - assert!(rows[0].pending_sync); -} - -#[test] -fn migrate_personas_skips_builtins() { - use crate::managed_agents::retention::{get_retained_personas, open_retention_db}; - - let base = tempfile::tempdir().unwrap(); - write_base_personas( - base.path(), - &serde_json::json!([{ - "id": "builtin:solo", - "display_name": "Solo", - "system_prompt": "x", - "is_builtin": true, - "is_active": true, - "name_pool": [], - "env_vars": {}, - "created_at": "2025-01-01T00:00:00Z", - "updated_at": "2025-01-01T00:00:00Z" - }]), - ); - let keys = nostr::Keys::generate(); - - let migrated = migrate_personas_in_dir(base.path(), &keys).unwrap(); - assert_eq!(migrated, 0); - - let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); - let rows = get_retained_personas(&conn, &keys.public_key().to_hex()).unwrap(); - assert!(rows.is_empty()); -} - -#[test] -fn migrate_personas_unchanged_second_run_is_noop() { - let base = tempfile::tempdir().unwrap(); - write_base_personas(base.path(), &one_persona()); - let keys = nostr::Keys::generate(); - - // First run retains; second run with identical personas re-retains - // nothing — the per-coordinate content matches, so `pending_sync` is - // not churned. - assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); - assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 0); - assert!(!base.path().join("migration_state.json").exists()); -} - -#[test] -fn migrate_personas_new_persona_after_first_run_gets_retained() { - use crate::managed_agents::retention::{get_retained_personas, open_retention_db}; - - let base = tempfile::tempdir().unwrap(); - write_base_personas(base.path(), &one_persona()); - let keys = nostr::Keys::generate(); - let pubkey = keys.public_key().to_hex(); - - assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); - - // A persona added to personas.json after the first reconcile must be - // picked up — the whole-store sentinel that previously short-circuited - // this is gone. - let mut two = one_persona(); - two.as_array_mut().unwrap().push(serde_json::json!({ - "id": "test-writer", - "display_name": "Test Writer", - "system_prompt": "You write tests.", - "is_builtin": false, - "is_active": true, - "name_pool": [], - "env_vars": {}, - "created_at": "2025-01-02T00:00:00Z", - "updated_at": "2025-01-02T00:00:00Z" - })); - write_base_personas(base.path(), &two); - - assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); - - let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); - let rows = get_retained_personas(&conn, &pubkey).unwrap(); - assert_eq!(rows.len(), 2); -} - -#[test] -fn migrate_personas_edited_persona_re_retains_pending() { - use crate::managed_agents::retention::{get_retained_event, mark_synced, open_retention_db}; - use buzz_core_pkg::kind::KIND_PERSONA; - - let base = tempfile::tempdir().unwrap(); - write_base_personas(base.path(), &one_persona()); - let keys = nostr::Keys::generate(); - let pubkey = keys.public_key().to_hex(); - - assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); - - // Simulate the flush loop confirming the first publish. - let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); - let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer") - .unwrap() - .unwrap(); - mark_synced( - &conn, - KIND_PERSONA, - &pubkey, - "code-reviewer", - row.created_at, - &row.content, - ) - .unwrap(); - drop(conn); - - // Editing the persona on disk must re-retain it as pending so the edit - // reaches the relay on the next flush. - let mut edited = one_persona(); - edited.as_array_mut().unwrap()[0]["system_prompt"] = - serde_json::json!("You review code carefully."); - write_base_personas(base.path(), &edited); - - assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); - - let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); - let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer") - .unwrap() - .unwrap(); - assert!(row.pending_sync); - assert!(row.content.contains("carefully")); -} - -#[test] -fn migrate_personas_no_file_is_noop() { - let base = tempfile::tempdir().unwrap(); - let keys = nostr::Keys::generate(); - assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 0); -} - -/// F8: a future-dated retained head must be SUPERSEDED on a changed-content -/// migration, not silently skipped by `retain_event`'s `>=` guard. Without the -/// monotonic `created_at` bump the rebuilt event lands at `now <= head`, the -/// upsert's `WHERE excluded.created_at >= ...` drops the UPDATE, and `migrated` -/// over-reports. The bump (max(now, head+1)) guarantees supersession. -#[test] -fn migrate_personas_supersedes_future_dated_head() { - use crate::managed_agents::retention::{ - get_retained_event, open_retention_db, retain_event, RetainedEvent, - }; - use buzz_core_pkg::kind::KIND_PERSONA; - - let base = tempfile::tempdir().unwrap(); - write_base_personas(base.path(), &one_persona()); - let keys = nostr::Keys::generate(); - let pubkey = keys.public_key().to_hex(); - - // First migrate retains the persona at ~now. - assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1); - - // Force the retained head far into the future, simulating a clock-skewed or - // same-second `max(now, head+1)` interactive bump. - let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); - let head = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer") - .unwrap() - .unwrap(); - let future = nostr::Timestamp::now().as_secs() as i64 + 100_000; - retain_event( - &conn, - &RetainedEvent { - created_at: future, - pending_sync: false, - ..head - }, - ) - .unwrap(); - - // Change the persona body on disk, then migrate again. - let mut edited = one_persona(); - edited.as_array_mut().unwrap()[0]["system_prompt"] = - serde_json::json!("You review code very carefully."); - write_base_personas(base.path(), &edited); - - assert_eq!( - migrate_personas_in_dir(base.path(), &keys).unwrap(), - 1, - "changed content over a future-dated head must report a real migration" - ); - - let row = get_retained_event(&conn, KIND_PERSONA, &pubkey, "code-reviewer") - .unwrap() - .unwrap(); - // The new body actually landed (not silently skipped) ... - assert!( - row.content.contains("very carefully"), - "changed body must supersede the future-dated head, not be dropped" - ); - // ... at a created_at strictly past the future head (monotonic bump) ... - assert_eq!(row.created_at, future + 1); - // ... and is queued for republish. - assert!(row.pending_sync, "superseding row must be pending_sync"); -} - -fn write_base_teams(base_dir: &Path, records: &serde_json::Value) { - std::fs::write( - base_dir.join("teams.json"), - serde_json::to_string_pretty(records).unwrap(), - ) - .unwrap(); -} - -/// F8 for the team migration site — same supersede guarantee as personas. -#[test] -fn migrate_teams_supersedes_future_dated_head() { - use crate::managed_agents::retention::{ - get_retained_event, open_retention_db, retain_event, RetainedEvent, - }; - use buzz_core_pkg::kind::KIND_TEAM; - - let base = tempfile::tempdir().unwrap(); - let team = serde_json::json!([{ - "id": "my-team", - "name": "My Team", - "description": "first", - "persona_ids": ["code-reviewer"], - "is_builtin": false, - "created_at": "2025-01-01T00:00:00Z", - "updated_at": "2025-01-01T00:00:00Z" - }]); - write_base_teams(base.path(), &team); - let keys = nostr::Keys::generate(); - let pubkey = keys.public_key().to_hex(); - - assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1); - - let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); - let head = get_retained_event(&conn, KIND_TEAM, &pubkey, "my-team") - .unwrap() - .unwrap(); - let future = nostr::Timestamp::now().as_secs() as i64 + 100_000; - retain_event( - &conn, - &RetainedEvent { - created_at: future, - pending_sync: false, - ..head - }, - ) - .unwrap(); - - let mut edited = team.clone(); - edited.as_array_mut().unwrap()[0]["description"] = serde_json::json!("second"); - write_base_teams(base.path(), &edited); - - assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1); - - let row = get_retained_event(&conn, KIND_TEAM, &pubkey, "my-team") - .unwrap() - .unwrap(); - assert!(row.content.contains("second")); - assert_eq!(row.created_at, future + 1); - assert!(row.pending_sync); -} diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 5eb64b3a7f..cfb0593d59 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -6,6 +6,22 @@ use serde::{Deserialize, Deserializer, Serialize}; pub struct IdentityInfo { pub pubkey: String, pub display_name: String, + /// True when the app booted with an ephemeral key because the OS keyring + /// was empty despite a prior successful migration (key was externally + /// deleted). The frontend routes to the nsec re-import step when true. + /// Mutually exclusive with `locked`. + pub lost: bool, + /// True when the app booted with an ephemeral key because the OS keyring + /// holding the identity is unreachable this boot (keyring locked or + /// unavailable). The real key still exists in the keyring; the frontend + /// shows a "unlock the keyring and relaunch" screen. Mutually exclusive + /// with `lost`. + pub locked: bool, + /// True when the boot-time Phase 2 reset attempted a wipe but verification + /// failed. Identity resolution was skipped; the frontend shows a + /// reset-failed recovery screen. The sentinel is preserved so the next + /// relaunch retries the wipe automatically. + pub reset_failed: bool, } #[derive(Serialize, Deserialize)] @@ -16,11 +32,21 @@ pub struct ProfileInfo { pub about: Option, pub nip05_handle: Option, pub owner_pubkey: Option, + /// `true` when a real kind:0 event was found on the relay; `false` for the + /// synthesized fallback returned when no metadata event exists. The + /// onboarding gate uses this to distinguish "new user with no profile" from + /// "returning user whose display_name happens to be empty". + pub has_profile_event: bool, } #[derive(Serialize, Deserialize)] pub struct UserProfileSummaryInfo { pub display_name: Option, + /// Kind-0 `name` field, carried separately from `display_name` so clients + /// can match @mention text against either alias (agents and the CLI + /// resolve mentions server-side against `display_name` *or* `name`). + #[serde(default)] + pub name: Option, pub avatar_url: Option, pub nip05_handle: Option, pub owner_pubkey: Option, diff --git a/desktop/src-tauri/src/nostr_bind.rs b/desktop/src-tauri/src/nostr_bind.rs new file mode 100644 index 0000000000..69926466cc --- /dev/null +++ b/desktop/src-tauri/src/nostr_bind.rs @@ -0,0 +1,107 @@ +use chrono::{DateTime, Utc}; +use url::Url; + +pub(crate) const AUDIENCE: &str = "buzz:nostr-identity"; +pub(crate) const ACTION: &str = "bind_nostr_identity"; +pub(crate) const CONTENT: &str = ""; +pub(crate) const KIND: u16 = buzz_core_pkg::kind::KIND_NOSTR_IDENTITY_BINDING as u16; +pub(crate) const PROTOCOL: &str = "buzz-nostr-identity"; +pub(crate) const RETURN_MODE: &str = "clipboard"; +pub(crate) const VERSION: &str = "1"; + +const NONCE_CHARS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"; +const VERIFICATION_CODE_LENGTH: usize = 6; + +pub(crate) fn validate_challenge_id(challenge_id: &str) -> Result<(), String> { + if challenge_id.is_empty() { + return Err("challenge_id is required".into()); + } + uuid::Uuid::parse_str(challenge_id).map_err(|_| "invalid challenge_id".to_string())?; + Ok(()) +} + +pub(crate) fn validate_nonce(nonce: &str) -> Result<(), String> { + if nonce.is_empty() { + return Err("nonce is required".into()); + } + if nonce.len() != 43 || !nonce.chars().all(|ch| NONCE_CHARS.contains(ch)) { + return Err("invalid nonce".into()); + } + Ok(()) +} + +pub(crate) fn validate_verification_code(verification_code: &str) -> Result<(), String> { + if verification_code.len() != VERIFICATION_CODE_LENGTH + || !verification_code.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err("verification_code must be exactly 6 digits".into()); + } + Ok(()) +} + +pub(crate) fn validate_protocol_fields( + audience: &str, + action: &str, + protocol: &str, + version: &str, +) -> Result<(), String> { + if audience != AUDIENCE { + return Err("unsupported audience".into()); + } + if action != ACTION { + return Err("unsupported action".into()); + } + if protocol != PROTOCOL { + return Err("unsupported protocol".into()); + } + if version != VERSION { + return Err("unsupported version".into()); + } + Ok(()) +} + +pub(crate) fn validate_origin(origin: &str) -> Result<(), String> { + let parsed = Url::parse(origin).map_err(|error| format!("invalid origin: {error}"))?; + if parsed.scheme() != "https" { + return Err("origin must use https".into()); + } + if parsed.host_str().is_none() { + return Err("origin missing host".into()); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("origin must not include credentials".into()); + } + if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { + return Err("origin must not include path, query, or fragment".into()); + } + Ok(()) +} + +pub(crate) fn validate_expires_at_format(expires_at: &str) -> Result, String> { + DateTime::parse_from_rfc3339(expires_at) + .map_err(|error| format!("invalid expires_at: {error}")) + .map(|parsed| parsed.with_timezone(&Utc)) +} + +pub(crate) fn validate_expires_at(expires_at: &str) -> Result<(), String> { + let parsed = validate_expires_at_format(expires_at)?; + if parsed <= Utc::now() { + return Err("expires_at is expired".into()); + } + Ok(()) +} + +pub(crate) fn validate_signing_request( + challenge_id: &str, + nonce: &str, + verification_code: &str, + origin: &str, + expires_at: &str, +) -> Result<(), String> { + validate_challenge_id(challenge_id)?; + validate_nonce(nonce)?; + validate_verification_code(verification_code)?; + validate_origin(origin)?; + validate_expires_at(expires_at)?; + Ok(()) +} diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 0f416495d3..ec4970e0c9 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeSet, HashMap}; -use nostr::Event; +use nostr::{Event, ToBech32}; use serde_json::{json, Value}; use crate::models::*; @@ -304,6 +304,7 @@ pub fn profile_info_from_event(event: &Event) -> Result { about, nip05_handle, owner_pubkey: profile_valid_oa_owner_pubkey(event), + has_profile_event: true, }) } @@ -338,6 +339,7 @@ pub fn users_batch_from_events( .and_then(Value::as_str) .or_else(|| v.get("name").and_then(Value::as_str)) .map(str::to_string), + name: v.get("name").and_then(Value::as_str).map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), is_agent: owner_pubkey.is_some(), @@ -446,7 +448,8 @@ pub fn agents_from_events(events: &[Event]) -> Value { .map(|ev| { let mut v: Value = serde_json::from_str(&ev.content).unwrap_or_else(|_| json!({})); let pubkey = ev.pubkey.to_hex(); - let short_pubkey = pubkey[..8].to_string(); + // Full npub fallback — truncated prefixes are grindable (see pubkey-display). + let npub = ev.pubkey.to_bech32().unwrap_or_else(|_| pubkey.clone()); // Always overwrite the pubkey with the event author — it's the // authoritative source even if the content claims otherwise. if let Some(obj) = v.as_object_mut() { @@ -456,7 +459,7 @@ pub fn agents_from_events(events: &[Event]) -> Value { .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .map(str::to_string) - .unwrap_or_else(|| short_pubkey.clone()); + .unwrap_or_else(|| npub.clone()); if !obj.get("name").is_some_and(Value::is_string) { obj.insert("name".to_string(), json!(fallback_name)); } @@ -478,7 +481,7 @@ pub fn agents_from_events(events: &[Event]) -> Value { } else { v = json!({ "pubkey": pubkey, - "name": short_pubkey, + "name": npub, "agent_type": "agent", "channels": [], "channel_ids": [], diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index a0fb1c4bd0..646c19913a 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -486,14 +486,14 @@ pub async fn submit_event( // so the MutexGuard is dropped and the future remains Send. let url = format!("{}/events", relay_api_base_url_with_override(state)); let (auth_header, body_bytes) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; let event = builder .sign_with_keys(&keys) .map_err(|e| format!("failed to sign event: {e}"))?; let body = event.as_json().into_bytes(); let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body)?; (auth, body) - }; // keys lock dropped here + }; // keys dropped here let response = state .http_client @@ -532,9 +532,9 @@ pub async fn submit_signed_event( let url = format!("{}/events", relay_api_base_url_with_override(state)); let body_bytes = event.as_json().into_bytes(); let auth_header = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body_bytes)? - }; // keys lock dropped here + }; // keys dropped here let response = state .http_client diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs new file mode 100644 index 0000000000..d2e35e6839 --- /dev/null +++ b/desktop/src-tauri/src/reset.rs @@ -0,0 +1,847 @@ +//! Two-phase boot-time sentinel wipe. +//! +//! **Phase 1** (`write_sentinel`) — called by `sign_out`: writes a durable +//! reset-intent file outside every path that will be wiped. +//! +//! **Phase 2** (`run_boot_reset`) — called at the very top of `setup()` in +//! `lib.rs`, before migrations and identity resolution: if the sentinel is +//! present the wipe runs atomically and the app falls through into clean +//! onboarding. +//! +//! The sentinel lives at `/..reset-pending` +//! and survives the app-data wipe because the wipe targets the exact +//! app-data dir, not its parent. +//! +//! Idempotency: if the process crashes mid-wipe the sentinel is still present +//! on next boot and the wipe retries from the top. + +use std::path::{Path, PathBuf}; + +// ── Sentinel helpers ────────────────────────────────────────────────────────── + +/// Sentinel path: `/..reset-pending` +/// where `bundle_id` is the file-name component of `app_data_dir` +/// (e.g. `xyz.block.buzz.app` or `xyz.block.buzz.app.dev`). +pub(crate) fn sentinel_path(app_data_dir: &Path) -> PathBuf { + let bundle_id = app_data_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("buzz"); + let name = format!(".{bundle_id}.reset-pending"); + match app_data_dir.parent() { + Some(parent) => parent.join(name), + None => PathBuf::from(&name), + } +} + +/// Atomically write the sentinel file. Content is intentionally empty — +/// existence is the signal. +pub(crate) fn write_sentinel(app_data_dir: &Path) -> Result<(), String> { + let path = sentinel_path(app_data_dir); + std::fs::write(&path, b"").map_err(|e| format!("write sentinel {}: {e}", path.display())) +} + +/// Return `true` when the sentinel file exists. +pub(crate) fn check_sentinel(app_data_dir: &Path) -> bool { + sentinel_path(app_data_dir).exists() +} + +/// Remove the sentinel file. A missing file is not an error. +pub(crate) fn delete_sentinel(app_data_dir: &Path) -> Result<(), String> { + let path = sentinel_path(app_data_dir); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("delete sentinel {}: {e}", path.display())), + } +} + +// ── Keychain abstraction (enables unit testing) ─────────────────────────────── + +/// Keychain operations needed by the boot-time reset. +/// Implemented for `SecretStore`; a fake is used in tests. +pub(crate) trait ResetKeychain { + /// Delete the blob + all per-key legacy entries. + fn delete_all_with_legacy(&self) -> Result<(), String>; + /// Return `true` when all keychain shapes (main blob, DPK blob, per-key + /// entries) that `migrate_legacy_key` can consume are absent. + fn verify_fully_wiped(&self) -> bool; +} + +impl ResetKeychain for crate::secret_store::SecretStore { + fn delete_all_with_legacy(&self) -> Result<(), String> { + self.delete_all_with_legacy_cleanup() + } + + fn verify_fully_wiped(&self) -> bool { + self.verify_fully_wiped() + } +} + +// ── Result type ─────────────────────────────────────────────────────────────── + +/// Outcome of the boot-time reset check. +#[derive(Debug, Default)] +pub(crate) struct ResetOutcome { + /// Wipe completed successfully this boot — suppress nest migrations. + pub completed: bool, + /// Wipe was attempted but verification failed — surface error state. + pub failed: bool, +} + +// ── Boot-time reset ─────────────────────────────────────────────────────────── + +/// Wipe parameters assembled by `lib.rs` and passed into `run_boot_reset_with_keychain`. +pub(crate) struct ResetContext<'a> { + pub app_data_dir: &'a Path, + /// Legacy App Support dir for this build (Sprout import source). When + /// present and non-empty, wiped alongside `app_data_dir` to prevent + /// `migrate_legacy_app_data_dir` from restoring the old identity. + pub legacy_app_data_dir: Option, + /// Nest dir (`~/.buzz` or `~/.buzz-dev`) scoped to this build's variant, + /// injected so unit tests can override without touching the global OnceLock. + pub nest_dir: Option, + pub keychain: &'a dyn ResetKeychain, + pub home_dir: Option, + pub is_dev: bool, +} + +/// Entry point called from `lib.rs` setup (before migrations). +/// +/// Constructs a `SecretStore` for the running build's keyring service and +/// delegates to `run_boot_reset_with_keychain` for testable wipe logic. +pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { + if !check_sentinel(app_data_dir) { + return ResetOutcome::default(); + } + + let is_dev = app_data_dir + .file_name() + .and_then(|n| n.to_str()) + .map(crate::migration::is_dev_data_dir_name) + .unwrap_or(false); + + let store = crate::secret_store::SecretStore::keyring(crate::app_state::keyring_service()); + let home_dir = dirs::home_dir(); + let legacy_dir = crate::migration::legacy_app_data_dir(app_data_dir); + let nest_dir = crate::managed_agents::nest_dir(); + + let ctx = ResetContext { + app_data_dir, + legacy_app_data_dir: legacy_dir, + nest_dir, + keychain: &store, + home_dir, + is_dev, + }; + + run_boot_reset_with_keychain(ctx) +} + +/// Deterministic trash path: `.reset-trash`. Unlike PID-based names, +/// any boot can discover and clean trash from a prior crashed attempt. +fn trash_path(original: &Path) -> PathBuf { + original.with_file_name(format!( + "{}.reset-trash", + original + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("buzz") + )) +} + +/// Remove an existing reset-trash directory if present (from a prior crashed +/// attempt), then rename `src` into the deterministic trash path. Returns +/// `Ok(trash_path)` on success. +fn rename_to_trash(src: &Path) -> Result { + let dst = trash_path(src); + // Clear prior trash before renaming so a collision doesn't fail the rename. + if dst.exists() { + let _ = std::fs::remove_dir_all(&dst); + } + std::fs::rename(src, &dst) + .map_err(|e| format!("rename {} → {}: {e}", src.display(), dst.display()))?; + Ok(dst) +} + +/// Core wipe logic — separated for testing. +pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcome { + let app_data_dir = ctx.app_data_dir; + + // ── Step 1: rename app-data dir (atomic — sentinel survives the parent) ── + let trash_app = trash_path(app_data_dir); + + if app_data_dir.exists() { + if let Err(e) = rename_to_trash(app_data_dir) { + eprintln!("buzz-desktop reset: {e}"); + return ResetOutcome { + completed: false, + failed: true, + }; + } + } + + // ── Step 1b: rename legacy App Support dir (sprout import source) ──────── + let trash_legacy: Option = ctx.legacy_app_data_dir.as_ref().map(|l| trash_path(l)); + if let Some(ref legacy) = ctx.legacy_app_data_dir { + if legacy.exists() { + if let Err(e) = rename_to_trash(legacy) { + eprintln!("buzz-desktop reset: {e}"); + // Non-fatal for legacy dir — continue + } + } + } + + // ── Step 2: rename WebKit dir for this build ────────────────────────────── + let trash_webkit: Option = if let Some(ref home) = ctx.home_dir { + let bundle_id = app_data_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("buzz"); + let webkit_dir = home.join("Library").join("WebKit").join(bundle_id); + let tw = trash_path(&webkit_dir); + if webkit_dir.exists() { + if let Err(e) = rename_to_trash(&webkit_dir) { + eprintln!("buzz-desktop reset: {e}"); + // Non-fatal — continue + } + } + Some(tw) + } else { + None + }; + + // ── Step 3: remove nest, ~/.sprout, ~/.config/buzz-agent, CLI symlink ──── + if let Some(ref nest) = ctx.nest_dir { + let _ = std::fs::remove_dir_all(nest); + } + if let Some(ref home) = ctx.home_dir { + let _ = std::fs::remove_dir_all(home.join(".sprout")); + let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); + let link_name = crate::managed_agents::cli_link_name(ctx.is_dev); + let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name)); + } + + // ── Step 4: keychain — LAST so we can read keys before deleting ────────── + if let Err(e) = ctx.keychain.delete_all_with_legacy() { + eprintln!("buzz-desktop reset: keychain delete: {e}"); + // Keychain failure is fatal: keep sentinel, signal failure. + // Restore all three dirs so the app returns to a coherent pre-reset state. + if trash_app.exists() { + let _ = std::fs::rename(&trash_app, app_data_dir); + } + if let Some(ref legacy) = ctx.legacy_app_data_dir { + if let Some(ref tl) = trash_legacy { + if tl.exists() { + let _ = std::fs::rename(tl, legacy); + } + } + } + if let Some(ref home) = ctx.home_dir { + let bundle_id = app_data_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("buzz"); + let webkit_dir = home.join("Library").join("WebKit").join(bundle_id); + if let Some(ref tw) = trash_webkit { + if tw.exists() { + let _ = std::fs::rename(tw, &webkit_dir); + } + } + } + return ResetOutcome { + completed: false, + failed: true, + }; + } + + // ── Step 5: sweep ALL reset trash (including from prior crashed boots) ─── + let _ = std::fs::remove_dir_all(&trash_app); + if let Some(ref tl) = trash_legacy { + let _ = std::fs::remove_dir_all(tl); + } + if let Some(ref tw) = trash_webkit { + let _ = std::fs::remove_dir_all(tw); + } + + // ── Step 6: verify ──────────────────────────────────────────────────────── + let keychain_ok = ctx.keychain.verify_fully_wiped(); + let app_data_gone = !app_data_dir.exists(); + let legacy_gone = ctx + .legacy_app_data_dir + .as_ref() + .map(|p| !p.exists()) + .unwrap_or(true); + let nest_gone = ctx.nest_dir.as_ref().map(|n| !n.exists()).unwrap_or(true); + let trash_app_gone = !trash_app.exists(); + let trash_legacy_gone = trash_legacy.as_ref().map(|p| !p.exists()).unwrap_or(true); + let trash_webkit_gone = trash_webkit.as_ref().map(|p| !p.exists()).unwrap_or(true); + + if !keychain_ok + || !app_data_gone + || !legacy_gone + || !nest_gone + || !trash_app_gone + || !trash_legacy_gone + || !trash_webkit_gone + { + eprintln!( + "buzz-desktop reset: verification failed (keychain_wiped={keychain_ok}, \ + app_data_gone={app_data_gone}, legacy_gone={legacy_gone}, nest_gone={nest_gone}, \ + trash_app_gone={trash_app_gone}, trash_legacy_gone={trash_legacy_gone}, \ + trash_webkit_gone={trash_webkit_gone})" + ); + return ResetOutcome { + completed: false, + failed: true, + }; + } + + // ── Step 7: delete sentinel → success ──────────────────────────────────── + if let Err(e) = delete_sentinel(app_data_dir) { + eprintln!("buzz-desktop reset: delete sentinel: {e}"); + // Sentinel not deleted — keep failed=false so the app boots into + // onboarding, but on next boot the reset will retry (idempotent). + } + + ResetOutcome { + completed: true, + failed: false, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use tempfile::TempDir; + + // ── Fake keychain ───────────────────────────────────────────────────────── + + struct FakeKeychain { + delete_result: Result<(), String>, + /// Tracks number of times delete was called. + delete_calls: Cell, + /// Whether `verify_fully_wiped` returns true after a successful delete. + wiped_after_delete: bool, + /// When true, verify_fully_wiped always returns false regardless of + /// delete outcome — simulates a transient/unknown keychain error + /// during verification that cannot confirm absence. + verify_always_fails: bool, + } + + impl FakeKeychain { + fn ok() -> Self { + FakeKeychain { + delete_result: Ok(()), + delete_calls: Cell::new(0), + wiped_after_delete: true, + verify_always_fails: false, + } + } + + fn fail(msg: &str) -> Self { + FakeKeychain { + delete_result: Err(msg.to_string()), + delete_calls: Cell::new(0), + wiped_after_delete: false, + verify_always_fails: false, + } + } + + fn ok_but_not_wiped() -> Self { + FakeKeychain { + delete_result: Ok(()), + delete_calls: Cell::new(0), + wiped_after_delete: false, + verify_always_fails: false, + } + } + + /// Delete succeeds but verify returns false — simulates a transient + /// unknown error during verification (e.g. keyring constructor failure + /// or unclassified read error that cannot confirm absence). + fn ok_but_verify_fails() -> Self { + FakeKeychain { + delete_result: Ok(()), + delete_calls: Cell::new(0), + wiped_after_delete: true, + verify_always_fails: true, + } + } + } + + impl ResetKeychain for FakeKeychain { + fn delete_all_with_legacy(&self) -> Result<(), String> { + self.delete_calls.set(self.delete_calls.get() + 1); + self.delete_result.clone() + } + + fn verify_fully_wiped(&self) -> bool { + if self.verify_always_fails { + return false; + } + self.wiped_after_delete && self.delete_calls.get() > 0 + } + } + + fn make_app_data(tmp: &TempDir) -> PathBuf { + let dir = tmp + .path() + .join("Application Support") + .join("xyz.block.buzz.app"); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn make_ctx<'a>( + app_data_dir: &'a Path, + keychain: &'a dyn ResetKeychain, + is_dev: bool, + ) -> ResetContext<'a> { + ResetContext { + app_data_dir, + legacy_app_data_dir: None, + nest_dir: None, + keychain, + home_dir: None, // skip nest/sprout/CLI ops in unit tests + is_dev, + } + } + + // ── Test 1: no sentinel ─────────────────────────────────────────────────── + + #[test] + fn test_no_sentinel_returns_no_op() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let kc = FakeKeychain::ok(); + + let outcome = run_boot_reset(app_data.as_path()); + assert!(!outcome.completed, "no sentinel → not completed"); + assert!(!outcome.failed, "no sentinel → not failed"); + assert_eq!(kc.delete_calls.get(), 0, "keychain not touched"); + assert!(app_data.exists(), "app-data dir untouched"); + } + + // ── Test 2: full wipe succeeds ──────────────────────────────────────────── + + #[test] + fn test_sentinel_present_full_wipe_succeeds() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + + // Also create a legacy App Support dir (sprout import source). + let legacy_dir = tmp + .path() + .join("Application Support") + .join("xyz.block.sprout.app"); + std::fs::create_dir_all(&legacy_dir).unwrap(); + std::fs::write(legacy_dir.join("identity.key"), b"old-identity").unwrap(); + + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + + let ctx = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: Some(legacy_dir.clone()), + nest_dir: None, + keychain: &kc, + home_dir: None, + is_dev: false, + }; + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.completed, "should complete"); + assert!(!outcome.failed, "should not fail"); + assert!(!app_data.exists(), "app-data must be gone"); + assert!(!legacy_dir.exists(), "legacy app-data must be gone"); + assert!(!sentinel_path(&app_data).exists(), "sentinel must be gone"); + assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once"); + } + + // ── Test 3: keychain failure keeps sentinel ──────────────────────────────── + + #[test] + fn test_sentinel_present_keychain_failure_keeps_sentinel() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::fail("keychain unavailable"); + let ctx = make_ctx(&app_data, &kc, false); + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(!outcome.completed); + assert!(outcome.failed); + assert!( + sentinel_path(&app_data).exists(), + "sentinel must be preserved on failure" + ); + } + + // ── Test 4: app-data rename works but verify fails ──────────────────────── + + #[test] + fn test_sentinel_present_verify_failure_keeps_sentinel() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + write_sentinel(&app_data).unwrap(); + // Keychain delete "succeeds" but verify_fully_wiped still returns false. + let kc = FakeKeychain::ok_but_not_wiped(); + let ctx = make_ctx(&app_data, &kc, false); + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(!outcome.completed); + assert!(outcome.failed); + assert!(sentinel_path(&app_data).exists(), "sentinel preserved"); + } + + // ── Test 5: crash-then-retry completes ─────────────────────────────────── + + #[test] + fn test_crash_then_retry_completes() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + write_sentinel(&app_data).unwrap(); + + // First run — keychain fails (simulates a crash mid-wipe). + let kc1 = FakeKeychain::fail("transient error"); + let ctx1 = make_ctx(&app_data, &kc1, false); + let first = run_boot_reset_with_keychain(ctx1); + assert!(first.failed); + assert!( + sentinel_path(&app_data).exists(), + "sentinel must survive first attempt" + ); + + // Second run — keychain succeeds. App-data dir was renamed but we need + // to recreate it for the test (the wipe tried to rename it). + // In production the dir would already be gone; here it was renamed to + // .trash- and then not cleaned up (keychain failed before cleanup). + // Create a fresh app-data dir to simulate a reboot where app recreated it. + std::fs::create_dir_all(&app_data).unwrap(); + + let kc2 = FakeKeychain::ok(); + let ctx2 = make_ctx(&app_data, &kc2, false); + let second = run_boot_reset_with_keychain(ctx2); + assert!(second.completed, "second attempt must complete"); + assert!(!second.failed); + assert!( + !sentinel_path(&app_data).exists(), + "sentinel cleared on success" + ); + } + + // ── Test 6: dev build wipes dev nest, leaves prod nest intact ──────────── + + #[test] + fn test_dev_build_wipes_dev_nest_not_prod() { + let tmp = TempDir::new().unwrap(); + + // Create both nests. + let dev_nest = tmp.path().join(".buzz-dev"); + let prod_nest = tmp.path().join(".buzz"); + std::fs::create_dir_all(&dev_nest).unwrap(); + std::fs::create_dir_all(&prod_nest).unwrap(); + + let app_data = tmp + .path() + .join("Application Support") + .join("xyz.block.buzz.app.dev"); + std::fs::create_dir_all(&app_data).unwrap(); + write_sentinel(&app_data).unwrap(); + + let kc = FakeKeychain::ok(); + let ctx = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: None, + nest_dir: Some(dev_nest.clone()), + keychain: &kc, + home_dir: None, + is_dev: true, + }; + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.completed, "wipe must complete"); + assert!(!dev_nest.exists(), "dev nest must be wiped"); + assert!(prod_nest.exists(), "prod nest must survive"); + } + + // ── Test 7: prod build wipes prod nest, leaves dev nest intact ─────────── + + #[test] + fn test_prod_build_wipes_prod_nest_not_dev() { + let tmp = TempDir::new().unwrap(); + + // Create both nests. + let dev_nest = tmp.path().join(".buzz-dev"); + let prod_nest = tmp.path().join(".buzz"); + std::fs::create_dir_all(&dev_nest).unwrap(); + std::fs::create_dir_all(&prod_nest).unwrap(); + + let app_data = tmp + .path() + .join("Application Support") + .join("xyz.block.buzz.app"); + std::fs::create_dir_all(&app_data).unwrap(); + write_sentinel(&app_data).unwrap(); + + let kc = FakeKeychain::ok(); + let ctx = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: None, + nest_dir: Some(prod_nest.clone()), + keychain: &kc, + home_dir: None, + is_dev: false, + }; + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.completed, "wipe must complete"); + assert!(!prod_nest.exists(), "prod nest must be wiped"); + assert!(dev_nest.exists(), "dev nest must survive"); + } + + // ── Test 8: legacy app-data removed on reset ────────────────────────────── + + #[test] + fn test_legacy_app_data_removed_on_reset() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + + // Seed a legacy sprout dir with data that would be re-imported. + let legacy_dir = tmp + .path() + .join("Application Support") + .join("xyz.block.sprout.app"); + std::fs::create_dir_all(legacy_dir.join("agents")).unwrap(); + std::fs::write(legacy_dir.join("identity.key"), b"sprout-nsec").unwrap(); + + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let ctx = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: Some(legacy_dir.clone()), + nest_dir: None, + keychain: &kc, + home_dir: None, + is_dev: false, + }; + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.completed, "reset must complete"); + assert!(!legacy_dir.exists(), "legacy app-data dir must be removed"); + } + + // ── Test 9: unknown error during delete → failed, sentinel kept ──────── + + #[test] + fn test_unknown_delete_error_keeps_sentinel() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + write_sentinel(&app_data).unwrap(); + // Simulates an unclassified/transient keychain error during delete. + let kc = FakeKeychain::fail("unknown transient keychain error"); + let ctx = make_ctx(&app_data, &kc, false); + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.failed, "unknown delete error must fail"); + assert!(!outcome.completed, "must not complete on delete error"); + assert!( + sentinel_path(&app_data).exists(), + "sentinel must survive unknown delete error" + ); + } + + // ── Test 10: unknown error during verify → failed, sentinel kept ───── + + #[test] + fn test_unknown_verify_error_keeps_sentinel() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + write_sentinel(&app_data).unwrap(); + // Delete succeeds but verification fails — simulates a transient + // keychain error (e.g. constructor failure, unclassified read error) + // that cannot confirm absence. The sentinel must survive. + let kc = FakeKeychain::ok_but_verify_fails(); + let ctx = make_ctx(&app_data, &kc, false); + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.failed, "unknown verify error must fail"); + assert!(!outcome.completed, "must not complete on verify error"); + assert!( + sentinel_path(&app_data).exists(), + "sentinel must survive unknown verify error" + ); + } + + // ── Test 11: completed dev reset suppresses dev repos-dir migration ──── + // Behavioral test at the composed seam: exercises real filesystem effects + // through maybe_migrate_dev_repos_dir, not just the boolean predicate. + + #[test] + fn test_completed_dev_reset_suppresses_dev_repos_dir_migration() { + let tmp = TempDir::new().unwrap(); + let app_data = tmp + .path() + .join("Application Support") + .join("xyz.block.buzz.app.dev"); + std::fs::create_dir_all(&app_data).unwrap(); + write_sentinel(&app_data).unwrap(); + + // Seed prod ~/.buzz/.repos-dir so the migration has something to copy. + let home = tmp.path().join("home"); + let prod_nest = home.join(".buzz"); + std::fs::create_dir_all(&prod_nest).unwrap(); + std::fs::write(prod_nest.join(".repos-dir"), "/some/workspace").unwrap(); + + let dev_nest = tmp.path().join(".buzz-dev"); + + // Run a real reset and take the REAL outcome.completed. + let kc = FakeKeychain::ok(); + let ctx = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: None, + nest_dir: Some(dev_nest.clone()), + keychain: &kc, + home_dir: None, + is_dev: true, + }; + let outcome = run_boot_reset_with_keychain(ctx); + assert!(outcome.completed, "reset must complete"); + + // Arm 1: completed dev reset → dev nest must NOT get .repos-dir. + crate::migration::maybe_migrate_dev_repos_dir(true, outcome.completed, &home, &dev_nest); + assert!( + !dev_nest.join(".repos-dir").exists(), + "completed dev reset must suppress .repos-dir import into dev nest" + ); + + // Arm 2 (positive control): non-reset dev boot → dev nest IS created + // with .repos-dir copied. This proves the test would have caught the + // pass-3 resurrection live. + let dev_nest_2 = tmp.path().join(".buzz-dev-control"); + crate::migration::maybe_migrate_dev_repos_dir(true, false, &home, &dev_nest_2); + assert!( + dev_nest_2.join(".repos-dir").exists(), + "non-reset dev boot must import .repos-dir into dev nest" + ); + assert_eq!( + std::fs::read_to_string(dev_nest_2.join(".repos-dir")).unwrap(), + "/some/workspace", + ".repos-dir content must match prod source" + ); + + // Arm 3: prod build (is_dev=false) → nothing created regardless. + let dev_nest_3 = tmp.path().join(".buzz-dev-prod"); + crate::migration::maybe_migrate_dev_repos_dir(false, false, &home, &dev_nest_3); + assert!( + !dev_nest_3.join(".repos-dir").exists(), + "prod builds must never run dev repos-dir migration" + ); + } + + // ── Test 12: crash-after-rename retry cleans prior trash ───────────── + + #[test] + fn test_crash_retry_cleans_prior_deterministic_trash() { + let tmp = TempDir::new().unwrap(); + let app_support = tmp.path().join("Application Support"); + let app_data = app_support.join("xyz.block.buzz.app"); + std::fs::create_dir_all(&app_data).unwrap(); + write_sentinel(&app_data).unwrap(); + + // Simulate a prior crashed boot: originals absent, deterministic trash + // present from the crash (as if the process renamed then died). + let trash_app_dir = app_support.join("xyz.block.buzz.app.reset-trash"); + std::fs::create_dir_all(&trash_app_dir).unwrap(); + std::fs::write(trash_app_dir.join("identity.key"), b"old-key").unwrap(); + + // Original is gone (crashed mid-wipe), so remove it for the retry. + std::fs::remove_dir_all(&app_data).unwrap(); + + let kc = FakeKeychain::ok(); + let ctx = make_ctx(&app_data, &kc, false); + + let outcome = run_boot_reset_with_keychain(ctx); + assert!(outcome.completed, "retry must complete"); + assert!( + !trash_app_dir.exists(), + "prior crash trash must be cleaned by retry" + ); + assert!( + !sentinel_path(&app_data).exists(), + "sentinel must be cleared" + ); + } + + // ── Test 13: keychain-fail restores all dirs, retry cleans trash ────── + + #[test] + fn test_keychain_fail_restores_all_then_retry_cleans() { + let tmp = TempDir::new().unwrap(); + let app_support = tmp.path().join("Application Support"); + let app_data = app_support.join("xyz.block.buzz.app"); + std::fs::create_dir_all(&app_data).unwrap(); + std::fs::write(app_data.join("config.json"), b"{}").unwrap(); + + let legacy = app_support.join("xyz.block.sprout.app"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("identity.key"), b"sprout-key").unwrap(); + + write_sentinel(&app_data).unwrap(); + + // First attempt: keychain fails → dirs restored. + let kc1 = FakeKeychain::fail("keychain locked"); + let ctx1 = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: Some(legacy.clone()), + nest_dir: None, + keychain: &kc1, + home_dir: Some(tmp.path().to_path_buf()), + is_dev: false, + }; + let first = run_boot_reset_with_keychain(ctx1); + assert!(first.failed, "first attempt must fail"); + assert!( + app_data.exists(), + "app-data must be restored after keychain fail" + ); + assert!( + legacy.exists(), + "legacy dir must be restored after keychain fail" + ); + assert!(sentinel_path(&app_data).exists(), "sentinel survives"); + + // Second attempt: keychain succeeds → everything cleaned including + // any residual trash from prior attempts. + let kc2 = FakeKeychain::ok(); + let ctx2 = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: Some(legacy.clone()), + nest_dir: None, + keychain: &kc2, + home_dir: Some(tmp.path().to_path_buf()), + is_dev: false, + }; + let second = run_boot_reset_with_keychain(ctx2); + assert!(second.completed, "second attempt must complete"); + assert!(!app_data.exists(), "app-data must be gone"); + assert!(!legacy.exists(), "legacy must be gone"); + // No trash directories should remain. + let trash_app = app_support.join("xyz.block.buzz.app.reset-trash"); + let trash_legacy = app_support.join("xyz.block.sprout.app.reset-trash"); + assert!(!trash_app.exists(), "app trash must be cleaned"); + assert!(!trash_legacy.exists(), "legacy trash must be cleaned"); + } +} diff --git a/desktop/src-tauri/src/secret_store.rs b/desktop/src-tauri/src/secret_store.rs index ab6a4c267c..43854761b5 100644 --- a/desktop/src-tauri/src/secret_store.rs +++ b/desktop/src-tauri/src/secret_store.rs @@ -575,6 +575,45 @@ impl SecretStore { } } + /// Read the secret for `key` without any legacy-migration side effects. + /// + /// Read the entire blob without any legacy-migration side effects. + /// + /// Returns the full key→value map when a blob exists, `Ok(None)` when no + /// blob has been written yet, and `Err` only when the backend is + /// unavailable. Never calls `migrate_legacy_key`. + pub fn load_all_readonly(&self) -> Result>, String> { + #[cfg(feature = "system-keyring")] + { + self.load_blob() + } + #[cfg(not(feature = "system-keyring"))] + { + Err("system-keyring feature disabled".to_string()) + } + } + + /// Insert all entries from `entries` into the blob in a single mutation. + /// + /// Entries that already exist in the blob are overwritten; entries not + /// present in `entries` are left unchanged. If the resulting blob is + /// identical to what is already stored, no keychain write occurs. + pub fn store_all(&self, entries: &HashMap) -> Result<(), String> { + #[cfg(feature = "system-keyring")] + { + self.mutate_blob(|map| { + for (k, v) in entries { + map.insert(k.clone(), v.clone()); + } + }) + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = entries; + Err("system-keyring feature disabled".to_string()) + } + } + /// On first launch after upgrading from the per-key DPK format, read the /// old DPK entry for `key`, write it into a new blob, and delete the old /// item. Returns `Ok(None)` when no old entry exists. @@ -655,6 +694,36 @@ impl SecretStore { } } + /// Verify that `key` holds `expected` by reading directly from the OS + /// backend, bypassing the in-process cache. This is the key innovation for + /// read-back verification: it proves the OS keyring round-trip, not just + /// that the in-process cache was updated. + /// + /// Returns `Ok(true)` when the stored value matches `expected`, `Ok(false)` + /// when the entry is absent or holds a different value, and `Err` when the + /// backend is unavailable. + pub fn verify_stored_raw(&self, key: &str, expected: &str) -> Result { + #[cfg(feature = "system-keyring")] + { + let raw = self.read_blob_raw()?; + match raw { + None => Ok(false), + Some(bytes) => { + let json = String::from_utf8(bytes).map_err(|e| format!("blob utf8: {e}"))?; + let map = + serde_json::from_str::>(&json) + .map_err(|e| format!("blob json: {e}"))?; + Ok(map.get(key).is_some_and(|v| v == expected)) + } + } + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = (key, expected); + Err("system-keyring feature disabled".to_string()) + } + } + /// Store `value` for `key`. Reports `Err` on availability failures — callers /// decide whether to fall back to file storage. pub fn store(&self, key: &str, value: &str) -> Result<(), String> { @@ -671,6 +740,163 @@ impl SecretStore { } } + /// Delete the entire keychain blob for this service, plus all legacy per-key + /// entries that could resurrect an identity on next boot. + /// + /// Order of operations: + /// 1. Read the blob to collect every key name (e.g. `identity`, agent keys). + /// 2. Delete legacy per-key DPK entries for every key + the DPK blob itself. + /// 3. Delete legacy per-key keyring entries for every key. + /// 4. Delete the blob entry. + /// 5. Clear the in-memory cache. + /// + /// This is the correct wipe path for sign-out: the old `delete_all` skipped + /// step 1–3 so stale per-key entries could be re-imported on the next launch + /// via `migrate_legacy_key`. This method prevents that resurrection. + pub fn delete_all_with_legacy_cleanup(&self) -> Result<(), String> { + #[cfg(feature = "system-keyring")] + { + let _lock = acquire_blob_lock(&self.service)?; + + // Step 1: read current blob keys (best-effort; no entry = empty set). + let blob_keys: Vec = match self.read_blob_raw() { + Ok(Some(bytes)) => { + let json = String::from_utf8(bytes).unwrap_or_default(); + serde_json::from_str::>(&json) + .map(|m| m.into_keys().collect()) + .unwrap_or_default() + } + _ => vec![], + }; + + // Always include "identity" even if the blob is empty or absent — + // it may exist only as a legacy per-key entry. + let mut all_keys = blob_keys; + if !all_keys.contains(&"identity".to_string()) { + all_keys.push("identity".to_string()); + } + + // Steps 2 & 3: delete legacy per-key entries for every key. + for key in &all_keys { + #[cfg(target_os = "macos")] + { + match delete_generic_password_options(dpk_opts(&self.service, key)) { + Ok(()) => {} + Err(ref e) if is_not_found(e) => {} + Err(ref e) if is_dpk_unavailable(e) => {} + Err(e) => return Err(format!("dpk per-key delete {key}: {e}")), + } + } + { + let entry = keyring_entry(&self.service, key) + .map_err(|e| format!("keyring entry constructor {key}: {e}"))?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(e) if is_keyring_availability_error(&e.to_string()) => { + return Err(format!("keyring unavailable deleting {key}: {e}")); + } + Err(e) => { + return Err(format!("keyring per-key delete {key}: {e}")); + } + } + } + } + // Step 2 (cont.): also delete the legacy DPK blob written by #1267. + #[cfg(target_os = "macos")] + { + match delete_generic_password_options(dpk_opts(&self.service, BLOB_KEY)) { + Ok(()) => {} + Err(ref e) if is_not_found(e) => {} + Err(ref e) if is_dpk_unavailable(e) => {} + Err(e) => return Err(format!("dpk blob delete: {e}")), + } + } + + // Step 4: delete the main blob entry. + { + let entry = keyring_entry(&self.service, BLOB_KEY) + .map_err(|e| format!("keyring entry constructor blob: {e}"))?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(e) if is_keyring_availability_error(&e.to_string()) => { + return Err(format!("keyring unavailable: {e}")); + } + Err(e) => { + return Err(format!("keyring blob delete: {e}")); + } + } + } + + // Step 5: clear the in-memory cache. + let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + Ok(()) + } + #[cfg(not(feature = "system-keyring"))] + { + Ok(()) // No-op: no keyring, nothing to delete. + } + } + + /// Verify no identity-bearing keychain entry survives in any shape + /// that `load("identity")` → `migrate_legacy_key` can consume: + /// main blob, DPK blob (`BLOB_KEY`), and per-key `"identity"`. + /// + /// Returns `true` when all three shapes are absent (or inaccessible in an + /// expected way), `false` when any entry is found or the keychain is + /// unavailable (fail-closed). + pub fn verify_fully_wiped(&self) -> bool { + #[cfg(feature = "system-keyring")] + { + // 1. Main blob must be absent. + match self.read_blob_raw() { + Ok(None) => {} + Ok(Some(_)) => return false, + Err(_) => return false, + } + // 2. Per-key "identity" via legacy keyring must be absent. + match keyring_entry(&self.service, "identity") { + Ok(entry) => match entry.get_password() { + Err(keyring::Error::NoEntry) => {} + Ok(_) => return false, + // Any other error (availability, unknown, transient) → fail closed. + // Only explicit NoEntry is proof of absence. + Err(_) => return false, + }, + // Constructor failure → cannot verify → fail closed. + Err(_) => return false, + } + // 3. DPK blob (macOS only). + #[cfg(target_os = "macos")] + { + match generic_password(dpk_opts(&self.service, BLOB_KEY)) { + Err(ref e) if is_not_found(e) => {} + // dpk-unavailable is symmetric with load(): if load() can't + // consume DPK in this state, a surviving entry is harmless. + Err(ref e) if is_dpk_unavailable(e) => {} + Ok(_) => return false, + // Any other error → fail closed (not proof of absence). + Err(_) => return false, + } + // 4. Per-key DPK "identity" (macOS only). + match generic_password(dpk_opts(&self.service, "identity")) { + Err(ref e) if is_not_found(e) => {} + // dpk-unavailable: symmetric with load() — if load() can't + // read DPK, a surviving entry can't resurrect identity. + Err(ref e) if is_dpk_unavailable(e) => {} + Ok(_) => return false, + // Any other error → fail closed. + Err(_) => return false, + } + } + true + } + #[cfg(not(feature = "system-keyring"))] + { + true // No keyring = nothing to verify. + } + } + /// Delete the secret for `key`. A missing entry is not an error. pub fn delete(&self, key: &str) -> Result<(), String> { #[cfg(feature = "system-keyring")] @@ -1039,4 +1265,42 @@ mod tests { // Cleanup. let _ = store2.delete(key); } + + #[ignore = "requires real OS keychain (run locally)"] + #[test] + fn delete_all_with_legacy_cleanup_removes_per_key_identity() { + let svc = "buzz-test-delete-all-legacy"; + let key = "identity"; + let value = "nsec1legacytest"; + + // Seed a legacy per-key entry (old format, pre-blob migration). + let entry = keyring_entry(svc, key).unwrap(); + entry.set_password(value).unwrap(); + + // Also seed a blob with a different key to exercise the full path. + let store = SecretStore::keyring(svc); + store.store("agent:abc123", "nsec1agent").unwrap(); + + // Legacy per-key identity should be discoverable via probe. + let store2 = SecretStore::keyring(svc); + assert_eq!(store2.probe(key), KeyringProbe::Present); + + // Wipe everything via the sign-out path. + store2.delete_all_with_legacy_cleanup().unwrap(); + + // Fresh store — neither the blob nor the per-key entry should remain. + let store3 = SecretStore::keyring(svc); + assert_eq!( + store3.probe(key), + KeyringProbe::ReachableButEmpty, + "per-key identity must not survive delete_all_with_legacy_cleanup" + ); + assert_eq!( + store3.load(key).unwrap(), + None, + "load must not resurrect the legacy per-key identity" + ); + // Agent key should also be gone. + assert_eq!(store3.load("agent:abc123").unwrap(), None); + } } diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index 532e005f82..7401b85cb8 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -7,8 +7,66 @@ use crate::managed_agents::{ }; use crate::util; +/// Install SIGINT/SIGTERM/SIGHUP cleanup on ctrlc's dedicated handler thread. +#[cfg(unix)] +pub(crate) fn install_signal_handler( + app: tauri::AppHandle, + shutdown_done: std::sync::Arc, +) { + use std::sync::atomic::Ordering; + + if let Err(error) = ctrlc::set_handler(move || { + app.state::() + .shutdown_started + .store(true, Ordering::SeqCst); + if !shutdown_done.swap(true, Ordering::SeqCst) { + let _ = shutdown_managed_agents(&app); + #[cfg(feature = "mesh-llm")] + shutdown_mesh_runtime(&app); + } + #[cfg(all(feature = "mesh-llm", target_os = "macos"))] + hard_exit_after_mesh_shutdown(); + #[cfg(not(all(feature = "mesh-llm", target_os = "macos")))] + std::process::exit(0); + }) { + eprintln!("buzz-desktop: failed to register signal handler: {error}"); + } +} + +#[cfg(all(feature = "mesh-llm", target_os = "macos"))] +pub(crate) fn hard_exit_after_mesh_shutdown() -> ! { + // SAFETY: all Buzz-managed subprocesses and the embedded Mesh runtime have + // been stopped. `_exit` intentionally skips only process-global C++ + // destructors and buffered stdio; no application state remains observable. + unsafe { libc::_exit(0) } +} + +#[cfg(feature = "mesh-llm")] +pub(crate) fn shutdown_mesh_runtime(app: &tauri::AppHandle) { + let app = app.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + let runtime = state.mesh_llm_runtime.lock().await.take(); + let result = match runtime { + Some(runtime) => runtime.stop().await, + None => Ok(()), + }; + let _ = tx.send(result); + }); + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(Ok(())) => {} + Ok(Err(error)) => eprintln!("buzz-desktop: failed to stop Mesh runtime: {error}"), + Err(error) => eprintln!("buzz-desktop: timed out stopping Mesh runtime: {error}"), + } +} + pub(crate) fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> { let state = app.state::(); + let _restore_transition = state + .managed_agent_restore_transition + .lock() + .map_err(|error| error.to_string())?; let _store_guard = state .managed_agents_store_lock .lock() diff --git a/desktop/src-tauri/src/util.rs b/desktop/src-tauri/src/util.rs index 6dea9d5af3..e35d4f87aa 100644 --- a/desktop/src-tauri/src/util.rs +++ b/desktop/src-tauri/src/util.rs @@ -42,6 +42,162 @@ pub fn slugify(name: &str, fallback: &str, max_len: usize) -> String { raw.trim_end_matches('-').to_string() } +// ── Safe symlink utilities ──────────────────────────────────────────────────── + +/// Create a symlink at `link` pointing to `target` on Unix; no-op on Windows. +/// +/// Worktree sync and nest setup are Unix-only features. This wrapper lets +/// call sites compile and run harmlessly on non-Unix platforms. +#[cfg(unix)] +pub(crate) fn create_symlink( + target: &std::path::Path, + link: &std::path::Path, +) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link) +} + +/// No-op on non-Unix platforms. +#[cfg(not(unix))] +pub(crate) fn create_symlink( + _target: &std::path::Path, + _link: &std::path::Path, +) -> std::io::Result<()> { + Ok(()) +} + +/// Returns `true` when `link` is a symlink whose stored target equals `target`. +/// +/// Compares the raw stored link value — no canonicalization — so relative +/// targets (e.g. `../../.agents/skills/buzz-cli`) compare correctly against +/// the literal string used to create them. +pub(crate) fn symlink_points_to(link: &std::path::Path, target: &std::path::Path) -> bool { + link.is_symlink() + && std::fs::read_link(link) + .map(|t| t == target) + .unwrap_or(false) +} + +/// Compute a collision-safe backup path for `dst`. +/// +/// The candidate is `/.bak.`. If that +/// path already exists (rare — same-millisecond collision or leftover backup), +/// appends `-2`, `-3`, … up to 100. Returns `None` when all 100 candidates +/// are occupied, indicating a backup failure. +pub(crate) fn backup_path(dst: &std::path::Path) -> Option { + let name = dst.file_name()?.to_str()?; + let stamp = Utc::now().format("%Y%m%d-%H%M%S%.3f"); + let base = format!("{name}.bak.{stamp}"); + let parent = dst.parent()?; + + let candidate = parent.join(&base); + if !candidate.exists() { + return Some(candidate); + } + // Collision — try suffixes -2 … -100. + for n in 2u32..=100 { + let candidate = parent.join(format!("{base}-{n}")); + if !candidate.exists() { + return Some(candidate); + } + } + None +} + +/// Replace `dst` with a symlink pointing to `src`, backing up any real +/// file or directory at `dst` first. +/// +/// Behaviour by what `dst` currently is: +/// +/// - **Already a correct symlink** (stored target == `src`): no-op, returns 0. +/// - **Wrong or broken symlink**: remove and replace; no backup — a symlink +/// holds no user data. Returns 1 on success, 0 if removal fails (the +/// subsequent `create_symlink` will surface EEXIST). +/// - **Real file or real directory**: rename to +/// `.bak.` (collision-safe, up to suffix -100). +/// If backup fails, `dst` is left untouched and 0 is returned. If the +/// backup succeeds but `create_symlink` fails, the backup is renamed back to +/// restore `dst`; if that rollback also fails, an actionable error is logged. +/// Returns 1 on success, 0 on any failure. +/// - **Absent**: creates the symlink and returns 1. +#[cfg(unix)] +pub(crate) fn replace_with_symlink(src: &std::path::Path, dst: &std::path::Path) -> u32 { + if dst.is_symlink() { + if symlink_points_to(dst, src) { + return 0; + } + // Wrong or broken symlink — remove and replace, no backup. + if let Err(e) = std::fs::remove_file(dst) { + eprintln!( + "buzz-desktop: symlink-util: failed to remove stale symlink {}: {e}", + dst.display() + ); + // Fall through — create_symlink will surface EEXIST. + } + } else if dst.exists() { + // Real file or real directory — back up before replacing. + let label = if dst.is_dir() { "dir" } else { "file" }; + let Some(bak) = backup_path(dst) else { + eprintln!( + "buzz-desktop: symlink-util: all backup paths occupied for {}; skipping", + dst.display() + ); + return 0; + }; + match std::fs::rename(dst, &bak) { + Ok(()) => eprintln!( + "buzz-desktop: symlink-util: backed up real {label} {} → {}", + dst.display(), + bak.display() + ), + Err(e) => { + eprintln!( + "buzz-desktop: symlink-util: failed to back up {label} {}: {e}", + dst.display() + ); + return 0; + } + } + // Backup succeeded — attempt symlink creation. + if let Err(e) = create_symlink(src, dst) { + eprintln!( + "buzz-desktop: symlink-util: failed to symlink {} → {}: {e}; attempting rollback", + dst.display(), + src.display() + ); + if let Err(rb_err) = std::fs::rename(&bak, dst) { + eprintln!( + "buzz-desktop: symlink-util: ROLLBACK FAILED ({rb_err}) — \ + {dst_disp} is still at {bak_disp}; \ + restore it manually: `mv {bak_disp} {dst_disp}`", + dst_disp = dst.display(), + bak_disp = bak.display(), + ); + } + return 0; + } + return 1; + } + + // dst was absent or was a symlink (already removed above). + match create_symlink(src, dst) { + Ok(()) => 1, + Err(e) => { + eprintln!( + "buzz-desktop: symlink-util: failed to symlink {} → {}: {e}", + dst.display(), + src.display() + ); + 0 + } + } +} + +/// No-op on non-Unix platforms — always returns 0. +#[cfg(not(unix))] +pub(crate) fn replace_with_symlink(_src: &std::path::Path, _dst: &std::path::Path) -> u32 { + 0 +} + #[cfg(test)] mod tests { use super::slugify; @@ -90,4 +246,156 @@ mod tests { // "abcde-----fghij" truncated at 10 → "abcde-----" → trimmed → "abcde" assert_eq!(slugify("abcde fghij", "x", 10), "abcde"); } + + // ── symlink_util ────────────────────────────────────────────────────────── + + #[cfg(unix)] + #[test] + fn replace_with_symlink_backs_up_real_file_and_creates_symlink() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("source.json"); + let dst = dir.path().join("dest.json"); + + std::fs::write(&src, r#"[{"id":"canonical"}]"#).unwrap(); + std::fs::write(&dst, r#"[{"id":"real-local-data"}]"#).unwrap(); + + let created = super::replace_with_symlink(&src, &dst); + assert_eq!(created, 1); + + assert!(dst.is_symlink()); + assert_eq!(std::fs::read_link(&dst).unwrap(), src); + + let bak_entry = std::fs::read_dir(dir.path()).unwrap().flatten().find(|e| { + e.file_name() + .to_str() + .map(|n| n.starts_with("dest.json.bak.")) + .unwrap_or(false) + }); + assert!(bak_entry.is_some(), "a .bak.* backup file must exist"); + let bak_content = std::fs::read_to_string(bak_entry.unwrap().path()).unwrap(); + assert_eq!(bak_content, r#"[{"id":"real-local-data"}]"#); + } + + #[cfg(unix)] + #[test] + fn replace_with_symlink_backs_up_real_dir_and_creates_symlink() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("canonical-teams"); + let dst = dir.path().join("local-teams"); + + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&dst).unwrap(); + std::fs::write(dst.join("stale.txt"), "old-content").unwrap(); + + let created = super::replace_with_symlink(&src, &dst); + assert_eq!(created, 1); + + assert!(dst.is_symlink()); + assert_eq!(std::fs::read_link(&dst).unwrap(), src); + + let bak_entry = std::fs::read_dir(dir.path()).unwrap().flatten().find(|e| { + e.file_name() + .to_str() + .map(|n| n.starts_with("local-teams.bak.")) + .unwrap_or(false) + }); + assert!(bak_entry.is_some(), "a .bak.* backup directory must exist"); + let bak_path = bak_entry.unwrap().path(); + assert!(bak_path.is_dir(), "backup must be a directory"); + assert_eq!( + std::fs::read_to_string(bak_path.join("stale.txt")).unwrap(), + "old-content" + ); + } + + #[cfg(unix)] + #[test] + fn replace_with_symlink_noop_when_already_correct_symlink() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("source.json"); + let dst = dir.path().join("dest.json"); + + std::fs::write(&src, "data").unwrap(); + std::os::unix::fs::symlink(&src, &dst).unwrap(); + + let created = super::replace_with_symlink(&src, &dst); + assert_eq!(created, 0); + + assert!(dst.is_symlink()); + assert_eq!(std::fs::read_link(&dst).unwrap(), src); + let bak_count = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .filter(|e| { + e.file_name() + .to_str() + .map(|n| n.contains(".bak.")) + .unwrap_or(false) + }) + .count(); + assert_eq!( + bak_count, 0, + "no backup should be created for a correct symlink" + ); + } + + #[cfg(unix)] + #[test] + fn replace_with_symlink_replaces_wrong_symlink_without_backup() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("source.json"); + let dst = dir.path().join("dest.json"); + let wrong_target = dir.path().join("wrong.json"); + + std::fs::write(&src, "data").unwrap(); + std::fs::write(&wrong_target, "wrong").unwrap(); + std::os::unix::fs::symlink(&wrong_target, &dst).unwrap(); + + let created = super::replace_with_symlink(&src, &dst); + assert_eq!(created, 1); + + assert!(dst.is_symlink()); + assert_eq!(std::fs::read_link(&dst).unwrap(), src); + + let bak_count = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .filter(|e| { + e.file_name() + .to_str() + .map(|n| n.contains(".bak.")) + .unwrap_or(false) + }) + .count(); + assert_eq!(bak_count, 0, "symlinks must not produce backups"); + } + + #[cfg(unix)] + #[test] + fn replace_with_symlink_replaces_broken_symlink_without_backup() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("source.json"); + let dst = dir.path().join("dest.json"); + + std::fs::write(&src, "data").unwrap(); + std::os::unix::fs::symlink(dir.path().join("nonexistent.json"), &dst).unwrap(); + + let created = super::replace_with_symlink(&src, &dst); + assert_eq!(created, 1); + + assert!(dst.is_symlink()); + assert_eq!(std::fs::read_link(&dst).unwrap(), src); + + let bak_count = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .filter(|e| { + e.file_name() + .to_str() + .map(|n| n.contains(".bak.")) + .unwrap_or(false) + }) + .count(); + assert_eq!(bak_count, 0, "broken symlinks must not produce backups"); + } } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index ab4c400c12..e1554d9db8 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.3.43", + "version": "0.4.6", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { @@ -21,6 +21,7 @@ "height": 600, "maximized": true, "visible": false, + "transparent": true, "titleBarStyle": "Overlay", "hiddenTitle": true, "dragDropEnabled": false, @@ -33,6 +34,7 @@ "minHeight": 500 } ], + "macOSPrivateApi": true, "security": { "csp": null } diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 86bce83763..93fb836739 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -1,4 +1,5 @@ -import { getCurrentWindow } from "@tauri-apps/api/window"; +import { isTauri } from "@tauri-apps/api/core"; +import { emit } from "@tauri-apps/api/event"; import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; import { Hexagon } from "lucide-react"; @@ -14,25 +15,125 @@ import { import { router } from "@/app/router"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; +import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSlideTransition"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; -import type { Workspace } from "@/features/workspaces/types"; -import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit"; -import { useNestNotifications } from "@/features/workspaces/useNestNotifications"; -import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; -import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup"; +import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen"; +import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen"; +import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen"; +import type { Community } from "@/features/communities/types"; +import { useCommunityInit } from "@/features/communities/useCommunityInit"; +import { useNestNotifications } from "@/features/communities/useNestNotifications"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; +import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; +import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { listenForDeepLinks } from "@/shared/deep-link"; import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { Spinner } from "@/shared/ui/spinner"; +import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; +import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee"; +import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { StepProgress } from "@/shared/ui/step-progress"; -const LOADING_TEXT = "Setting up your workspace..."; +const LOADING_TEXT = "Setting up your community..."; +// Minimum time the cold-boot splash stays on screen. A real boot resolves the +// community in well under 100ms, and the native window setup plus first paint +// can take longer than that — without a hold, the bee is unmounted before it is +// ever visible. The hold runs as an overlay above the already-mounted app, so +// time-to-interactive is unchanged; only the reveal waits. +const BOOT_SPLASH_MIN_VISIBLE_MS = 1_200; +const BOOT_SPLASH_FADE_MS = 200; +const INITIAL_RENDER_READY_EVENT = "initial-render-ready"; + +type BootSplashPhase = "holding" | "fading" | "done"; + +function useInitialRenderReady() { + useLayoutEffect(() => { + if (!isTauri()) { + return; + } + + void emit(INITIAL_RENDER_READY_EVENT); + }, []); +} + +// E2E runs skip the hold (it would slow every spec's boot and block pointer +// actionability); a spec can opt back in via __BUZZ_E2E__.bootSplashHoldMs. +function bootSplashHoldMs(): number { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { bootSplashHoldMs?: number }; + } + ).__BUZZ_E2E__; + if (e2e) { + return e2e.bootSplashHoldMs ?? 0; + } + return BOOT_SPLASH_MIN_VISIBLE_MS; +} + +function useBootSplashHold(): BootSplashPhase { + const [phase, setPhase] = useState(() => + bootSplashHoldMs() > 0 ? "holding" : "done", + ); + + useEffect(() => { + const holdMs = bootSplashHoldMs(); + if (holdMs <= 0) { + return; + } + const fadeTimer = window.setTimeout(() => setPhase("fading"), holdMs); + const doneTimer = window.setTimeout( + () => setPhase("done"), + holdMs + BOOT_SPLASH_FADE_MS, + ); + return () => { + window.clearTimeout(fadeTimer); + window.clearTimeout(doneTimer); + }; + }, []); + + return phase; +} + +// Animated Buzz mark for the loading gates. The static BuzzMark renders in +// normal flow and sizes the box — it's plain SVG (no JS/SMIL), so it paints on +// the very first frame even before scripting starts, avoiding a blank flash on +// hard reload. The animated FuzzyLogo is layered on top and takes over once it +// begins playing. +function BeeLoader({ + ariaLabel, + className, + tintClassName = "text-foreground", +}: { + ariaLabel: string; + className?: string; + tintClassName?: string; +}) { + return ( +
+ + +
+ ); +} + +// Cold boot gate: the theme-adaptive grainient background with a single +// centered Buzz bee flying over it — the same static mark as before, now with +// its wings flapping (ported from the Buzz website's wing-flap). Replaces the +// old "Setting up your community" text, which stays as an sr-only caption. function AppLoadingGate() { return (
- -

- {LOADING_TEXT} - -

+ {LOADING_TEXT} +
); } -// Quiet gate for switching between already-set-up workspaces: visually empty +// Quiet gate for switching between already-set-up communities: visually empty // unless the switch takes long, so fast switches don't flash the boot splash. -function WorkspaceSwitchGate() { +function CommunitySwitchGate() { const [showSpinner, setShowSpinner] = useState(false); useEffect(() => { @@ -69,13 +162,17 @@ function WorkspaceSwitchGate() { return (
- Switching workspace… + Switching community… {showSpinner ? ( -
); @@ -103,7 +200,7 @@ function OnboardingLoadingGate() { className="flex w-full flex-col items-center text-center" direction="forward" effect="none" - transitionKey="workspace-connecting" + transitionKey="community-connecting" >
+ + {advanced.length > 0 && advancedMode === "flat" ? (
+

+ Advanced +

{advanced.map((field) => ( ) { return Array.from(event.dataTransfer.types).includes("Files"); @@ -19,9 +48,16 @@ const AVATAR_APPLY_MOTION_TRANSITION = { ease: [0.23, 1, 0.32, 1], } as const; +type AvatarTab = "image" | "emoji"; + +type EmojiMartEmoji = { + native?: string; +}; + export function AgentCreationPreview({ avatarUrl, disabled = false, + hideEditControl = false, label, onClearAvatar, onUploadPendingChange, @@ -29,19 +65,43 @@ export function AgentCreationPreview({ }: { avatarUrl: string | null; disabled?: boolean; + /** When true, omit all upload/edit controls and render the avatar as a + * plain display element. Use in contexts where avatar editing is + * handled by an external affordance (e.g. AgentInstanceEditDialog). */ + hideEditControl?: boolean; label: string; onClearAvatar?: () => void; onUploadPendingChange?: (isPending: boolean) => void; onSelectAvatar: (avatarUrl: string) => void; }) { - const avatarEditClipId = React.useId().replace(/:/g, ""); const [isDragOverAvatar, setIsDragOverAvatar] = React.useState(false); const [isAvatarMenuOpen, setIsAvatarMenuOpen] = React.useState(false); const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); - const [isAvatarUrlInputFocused, setIsAvatarUrlInputFocused] = + const [activeTab, setActiveTab] = React.useState("image"); + const [selectedEmoji, setSelectedEmoji] = React.useState(null); + const [selectedColor, setSelectedColor] = React.useState( + DEFAULT_EMOJI_AVATAR_COLOR, + ); + // Whether the user has explicitly picked a color swatch (vs. the default). + // The color grid is always visible, so a user can choose a background color + // before their first emoji — in that case the first emoji must honor the + // chosen color instead of a random one. + const [hasChosenColor, setHasChosenColor] = React.useState(false); + const [customHue, setCustomHue] = React.useState(DEFAULT_CUSTOM_HUE); + const [customSaturation, setCustomSaturation] = React.useState( + DEFAULT_CUSTOM_SATURATION, + ); + const [customValue, setCustomValue] = React.useState(DEFAULT_CUSTOM_VALUE); + const [isCustomColorPickerOpen, setIsCustomColorPickerOpen] = React.useState(false); + const [isPopoverDragOver, setIsPopoverDragOver] = React.useState(false); + const [squishKey, setSquishKey] = React.useState(0); const avatarDragDepthRef = React.useRef(0); + const popoverDragDepthRef = React.useRef(0); const shouldReduceMotion = useReducedMotion(); + const emojiPickerContainerRef = React.useRef(null); + const emojiMartThemeVars = useEmojiMartThemeVars(); + const { burstEmoji } = useEmojiBurst(); const { inputRef: avatarUploadInputRef, isUploading, @@ -51,9 +111,22 @@ export function AgentCreationPreview({ uploadFile: uploadAvatarFile, handleFileChange: handleAvatarUploadFileChange, } = useAvatarUpload({ - onUploadSuccess: onSelectAvatar, + onUploadSuccess: (url) => { + onSelectAvatar(url); + setIsAvatarMenuOpen(false); + }, }); + useEmojiMartStyles( + emojiPickerContainerRef, + isAvatarMenuOpen && activeTab === "emoji", + ); + + const customColorDraft = React.useMemo( + () => hsvToHex(customHue, customSaturation, customValue), + [customHue, customSaturation, customValue], + ); + React.useEffect(() => { onUploadPendingChange?.(isUploading); return () => { @@ -61,12 +134,48 @@ export function AgentCreationPreview({ }; }, [isUploading, onUploadPendingChange]); + // Sync emoji state from avatarUrl when the popover opens React.useEffect(() => { if (isAvatarMenuOpen) { setAvatarUrlDraft(""); - setIsAvatarUrlInputFocused(false); + setIsPopoverDragOver(false); + popoverDragDepthRef.current = 0; + + const parsed = parseEmojiAvatarDataUrl(avatarUrl ?? ""); + if (parsed) { + setSelectedEmoji(parsed.emoji); + setSelectedColor(parsed.color); + setHasChosenColor(true); + setActiveTab("emoji"); + } else { + // Non-emoji avatar (image/URL or empty): clear any stale emoji + // selection so a later color-swatch tap can't re-apply an old emoji + // over the current avatar. + setSelectedEmoji(null); + setSelectedColor(DEFAULT_EMOJI_AVATAR_COLOR); + setHasChosenColor(false); + setActiveTab("image"); + } } - }, [isAvatarMenuOpen]); + }, [isAvatarMenuOpen, avatarUrl]); + + // Keep the custom color picker in sync when the selected color changes + React.useEffect(() => { + if (!isCustomColorPickerOpen || !selectedEmoji) { + return; + } + const nextAvatarUrl = emojiAvatarDataUrl(selectedEmoji, customColorDraft); + if (avatarUrl === nextAvatarUrl) { + return; + } + onSelectAvatar(nextAvatarUrl); + }, [ + avatarUrl, + customColorDraft, + isCustomColorPickerOpen, + onSelectAvatar, + selectedEmoji, + ]); function applyAvatarUrl() { const nextUrl = avatarUrlDraft.trim(); @@ -78,22 +187,59 @@ export function AgentCreationPreview({ setIsAvatarMenuOpen(false); } - const avatarClipStyle = React.useMemo( - () => ({ - clipPath: `url(#${avatarEditClipId})`, - transform: "translateZ(0)", - }), - [avatarEditClipId], - ); - const hasAvatarUrlDraft = avatarUrlDraft.trim().length > 0; + function applyEmojiAvatar(emoji: string, color = selectedColor) { + onSelectAvatar(emojiAvatarDataUrl(emoji, color)); + setSquishKey((key) => key + 1); + } + + function handleColorSelect(swatch: AvatarColorSwatch) { + if (disabled) { + return; + } + if (swatch === CUSTOM_AVATAR_COLOR_SWATCH) { + if (!selectedEmoji) { + return; + } + openCustomColorPicker(); + return; + } + setSelectedColor(swatch); + setHasChosenColor(true); + if (selectedEmoji) { + applyEmojiAvatar(selectedEmoji, swatch); + } + } + + function openCustomColorPicker() { + const nextColor = hexToHsv(selectedColor); + setCustomHue(normalizeHue(nextColor.hue)); + setCustomSaturation(nextColor.saturation); + setCustomValue(nextColor.value); + setIsCustomColorPickerOpen(true); + } + + function commitCustomColor() { + setSelectedColor(customColorDraft); + setHasChosenColor(true); + if (selectedEmoji) { + applyEmojiAvatar(selectedEmoji, customColorDraft); + } + setIsCustomColorPickerOpen(false); + } + const hasAvatar = (avatarUrl?.trim().length ?? 0) > 0; + const emojiAvatarPreview = React.useMemo( + () => parseEmojiAvatarDataUrl(avatarUrl ?? ""), + [avatarUrl], + ); const applyButtonTransition = shouldReduceMotion ? { duration: 0 } : AVATAR_APPLY_MOTION_TRANSITION; + // Outer avatar drag — only active when popover is closed const handleAvatarDragEnter = React.useCallback( (event: React.DragEvent) => { - if (disabled || !isAvatarFileDrag(event)) { + if (disabled || isAvatarMenuOpen || !isAvatarFileDrag(event)) { return; } event.preventDefault(); @@ -102,12 +248,12 @@ export function AgentCreationPreview({ event.dataTransfer.dropEffect = "copy"; setIsDragOverAvatar(true); }, - [disabled], + [disabled, isAvatarMenuOpen], ); const handleAvatarDragOver = React.useCallback( (event: React.DragEvent) => { - if (disabled || !isAvatarFileDrag(event)) { + if (disabled || isAvatarMenuOpen || !isAvatarFileDrag(event)) { return; } event.preventDefault(); @@ -115,12 +261,12 @@ export function AgentCreationPreview({ event.dataTransfer.dropEffect = "copy"; setIsDragOverAvatar(true); }, - [disabled], + [disabled, isAvatarMenuOpen], ); const handleAvatarDragLeave = React.useCallback( (event: React.DragEvent) => { - if (!isAvatarFileDrag(event)) { + if (isAvatarMenuOpen || !isAvatarFileDrag(event)) { return; } event.preventDefault(); @@ -130,12 +276,12 @@ export function AgentCreationPreview({ setIsDragOverAvatar(false); } }, - [], + [isAvatarMenuOpen], ); const handleAvatarDrop = React.useCallback( (event: React.DragEvent) => { - if (!isAvatarFileDrag(event)) { + if (isAvatarMenuOpen || !isAvatarFileDrag(event)) { return; } event.preventDefault(); @@ -151,91 +297,389 @@ export function AgentCreationPreview({ clearUploadError(); void uploadAvatarFile(file); }, + [ + clearUploadError, + disabled, + isAvatarMenuOpen, + isUploading, + uploadAvatarFile, + ], + ); + + // Popover-level drag — one big drop zone for the entire popover + const handlePopoverDragEnter = React.useCallback( + (event: React.DragEvent) => { + if (disabled || !isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + popoverDragDepthRef.current += 1; + event.dataTransfer.dropEffect = "copy"; + setIsPopoverDragOver(true); + setActiveTab("image"); + }, + [disabled], + ); + + const handlePopoverDragOver = React.useCallback( + (event: React.DragEvent) => { + if (disabled || !isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + }, + [disabled], + ); + + const handlePopoverDragLeave = React.useCallback( + (event: React.DragEvent) => { + if (!isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + popoverDragDepthRef.current = Math.max( + 0, + popoverDragDepthRef.current - 1, + ); + if (popoverDragDepthRef.current === 0) { + setIsPopoverDragOver(false); + } + }, + [], + ); + + const handlePopoverDrop = React.useCallback( + (event: React.DragEvent) => { + if (!isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + popoverDragDepthRef.current = 0; + setIsPopoverDragOver(false); + + const file = event.dataTransfer.files[0]; + if (!file || disabled || isUploading) { + return; + } + + clearUploadError(); + void uploadAvatarFile(file); + }, [clearUploadError, disabled, isUploading, uploadAvatarFile], ); + const isCustomColorPickerVisible = + isCustomColorPickerOpen && selectedEmoji !== null; + const avatarMenuContent = ( - - -
{ - event.preventDefault(); - event.stopPropagation(); - applyAvatarUrl(); - }} - > - - setIsAvatarUrlInputFocused(false)} - onChange={(event) => setAvatarUrlDraft(event.target.value)} - onFocus={() => setIsAvatarUrlInputFocused(true)} - placeholder={isAvatarUrlInputFocused ? "https://..." : "Use a URL"} - spellCheck={false} - value={avatarUrlDraft} - /> - - {hasAvatarUrlDraft ? ( - - - - ) : null} - -
- {hasAvatar && onClearAvatar ? ( - - ) : null} + + + } + > +
+ {/* Avatar is definition-level identity. hideEditControl suppresses + the internal pencil badge; the CTA below is the only edit path. */} +
+ setAvatarUrl("")} + onUploadPendingChange={setIsAvatarUploadPending} + onSelectAvatar={setAvatarUrl} + /> + {onEditLinkedPersona ? ( + + ) : ( +

+ Avatar is shared identity +

+ )} +
+
+ {/* Agent name */} +
+ +
+ setName(event.target.value)} + placeholder="Agent name" + value={name} + /> +
+
+ + {/* Who can talk to this agent */} + + + {/* Provider (runtime) */} +
+ + + {selectedRuntime ? ( +

+ Detected at{" "} + + {selectedRuntime.binaryPath ?? + selectedRuntime.command ?? + selectedRuntime.id} + +

+ ) : null} +
+ {/* LLM provider */} + {llmProviderFieldVisible ? ( +
+ + + {isCustomProviderEditing ? ( +
+ setProvider(event.target.value)} + placeholder="Custom provider ID" + value={provider} + /> +
+ ) : null} +
+ ) : null} + + {llmProviderFieldVisible && topLevelSecretEnvVar ? ( + { + setEnvVars((prev) => ({ + ...prev, + [topLevelSecretEnvVar]: next, + })); + }} + value={apiKeyValue} + /> + ) : null} + + {/* Model */} +
+ + + {showCustomModelInput ? ( +
+ setModel(event.target.value)} + placeholder="Custom model ID" + value={model} + /> +
+ ) : null} +

+ {modelDiscoveryLoading + ? "Loading models..." + : modelDiscoveryStatus !== null + ? modelDiscoveryStatus.message + : discoveredModelOptions !== null + ? "Saved changes take effect on the next start." + : "Select a provider above to see available models."} +

+
+ + + + {/* Advanced settings */} +
+ + {!showAdvancedFields && advancedInheritedSummary ? ( +

+ {advancedInheritedSummary} +

+ ) : null} + + + {showAdvancedFields ? ( + + + + ) : null} + +
+ + {/* Error */} + {updateMutation.error instanceof Error ? ( +

+ {updateMutation.error.message} +

+ ) : null} +
+
+ + + ); +} diff --git a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx new file mode 100644 index 0000000000..604bf9f703 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx @@ -0,0 +1,60 @@ +import { useAgentManagement } from "@/features/agents/useAgentManagement"; +import { AgentDialog } from "./AgentDialog"; +import { SecretRevealDialog } from "./SecretRevealDialog"; + +/** Global review surfaces opened by owned agents through the Buzz harness. */ +export function AgentManagementDialogs() { + const management = useAgentManagement(); + + return ( + <> + {management.request?.action === "create" ? ( + { + if (!open) management.dismiss(); + }} + onSubmitDefinition={management.submitCreate} + runtimes={management.runtimes} + runtimesLoading={management.runtimesLoading} + /> + ) : null} + {management.createdAgent ? ( + { + if (!open) management.dismissCreatedAgent(); + }} + onRetryAttachment={() => { + void management.retryAttachment(); + }} + /> + ) : null} + {management.request?.action === "update" ? ( + { + if (!open) management.dismiss(); + }} + onSubmit={management.submitUpdate} + open + runtimes={management.runtimes} + runtimesLoading={management.runtimesLoading} + submitLabel="Save changes" + title="Edit agent" + /> + ) : null} + + ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx index ef7d381e0d..a13ac8e072 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx @@ -97,7 +97,7 @@ function ToolCodeBlock({ }) { return (
-

+

{label}

 isAgentTurnLive(activeTurns, channelId),
     [activeTurns, channelId],
   );
-  const displayBlocks = React.useMemo(
-    () => buildTranscriptDisplayBlocks(items),
-    [items],
+
+  // Subscribe to the observer relay store so we read the latest-live-session-id
+  // reactively. We don't need the full snapshot — only the key for boundary labeling.
+  const getLatestLive = React.useCallback(
+    () => getLatestLiveSessionId(agentPubkey, channelId),
+    [agentPubkey, channelId],
   );
+  const latestLiveSessionId = React.useSyncExternalStore(
+    subscribeAgentObserverStore,
+    getLatestLive,
+  );
+
+  const displayBlocks = React.useMemo(
+    () => buildTranscriptDisplayBlocks(items, latestLiveSessionId),
+    [items, latestLiveSessionId],
+  );
+  // Derive the same block keys the DOM renders as `data-message-id` so
+  // useAnchoredScroll anchors on real DOM rows. Value-stabilized so the
+  // hook's restoration effect only fires when the ordered block sequence
+  // actually changes (same pattern as AgentSessionThreadPanel).
+  const blockKeys = React.useMemo(
+    () => (autoTail ? displayBlocks.map(getDisplayBlockKey) : []),
+    [autoTail, displayBlocks],
+  );
+  const stableBlockKeys = useStableArrayShallow(blockKeys);
+  const scrollMessages = React.useMemo(
+    () => stableBlockKeys.map((id) => ({ id })),
+    [stableBlockKeys],
+  );
+
   const scrollContainerRef = React.useRef(null);
   const contentRef = React.useRef(null);
   const anchoredScroll = useAnchoredScroll({
     channelId: autoTail ? (scrollScopeKey ?? agentPubkey) : null,
     contentRef,
     isLoading: false,
-    messages: items,
+    messages: scrollMessages,
     scrollContainerRef,
   });
 
@@ -280,6 +314,11 @@ function hasRenderableCompactBlock(block: TranscriptDisplayBlock) {
     return isRenderableCompactItem(block.item);
   }
 
+  // session-boundary dividers are not renderable content in compact view.
+  if (block.kind === "session-boundary") {
+    return false;
+  }
+
   return block.segments.some((segment) => {
     if (segment.kind === "item") {
       return isRenderableCompactItem(segment.item);
@@ -310,13 +349,6 @@ function TranscriptAcpSourceBadge({ source }: { source: string }) {
   );
 }
 
-function getDisplayBlockKey(block: TranscriptDisplayBlock) {
-  if (block.kind === "single") {
-    return block.item.id;
-  }
-  return `turn:${block.turnId}`;
-}
-
 function TranscriptDisplayBlockView({
   agentAvatarUrl,
   agentName,
@@ -339,6 +371,15 @@ function TranscriptDisplayBlockView({
   const hasCompletedInitialRenderRef = useHasCompletedInitialRender();
   const animateSegmentEnter = animationPreferenceEnabled && !shouldReduceMotion;
 
+  if (block.kind === "session-boundary") {
+    return (
+      
+    );
+  }
+
   if (block.kind === "single") {
     return (
       
     );
@@ -455,10 +497,10 @@ function SameKindSummaryItem({
 }) {
   const groupedFileEditDiffs = React.useMemo(
     () =>
-      summary.renderClass === "file-edit"
+      summary.renderClass === "file-edit" || summary.variant === "mixed"
         ? getGroupedFileEditDiffs(summary.items)
         : [],
-    [summary.items, summary.renderClass],
+    [summary.items, summary.renderClass, summary.variant],
   );
   const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs);
   const expandsToToolItems = summary.items.every(
@@ -467,6 +509,10 @@ function SameKindSummaryItem({
   const variant = useAgentSessionTranscriptVariant();
   const timestampsEnabled = useTranscriptTimestampsEnabled();
   const showTimestamp = timestampsEnabled && variant !== "compactPreview";
+  // Mixed bursts expand to their child segments in original order: raw tool
+  // rows plus nested same-kind summaries that joined the burst (which stay
+  // expandable to their own child rows).
+  const childSegments = summary.segments ?? null;
 
   return (
     <>
@@ -483,30 +529,52 @@ function SameKindSummaryItem({
         
-          {expandsToToolItems
-            ? summary.items.map((item) => (
-                
-              ))
-            : summary.items.map((item) => (
-                

- {item.type === "tool" - ? item.descriptor.preview || item.descriptor.label - : item.title} -

- ))} + {childSegments + ? childSegments.map((child) => + child.kind === "summary" ? ( + + ) : ( + + ), + ) + : expandsToToolItems + ? summary.items.map((item) => ( + + )) + : summary.items.map((item) => ( +

+ {item.type === "tool" + ? item.descriptor.preview || item.descriptor.label + : item.title} +

+ ))}
{showTimestamp ? ( @@ -550,15 +618,33 @@ function ToolRunSummaryLabel({ label: string; stats?: ActivityRowStats | null; }) { + const animationPreferenceEnabled = useTranscriptAnimationEnabled(); const parts = splitActivityRowLabel(label); if (!parts) { return {label}; } + // Streaming bursts grow their count in place ("Ran 16 tool calls" → + // "Ran 17 tool calls"); rolling the digits odometer-style makes the + // increment legible. AnimatedCount keeps an sr-only static value and + // falls back to static text under prefers-reduced-motion. + const countedObject = + animationPreferenceEnabled && typeof parts.object === "string" + ? splitActivityRowCountedObject(parts.object) + : null; + const object = countedObject ? ( + <> + + {countedObject.rest} + + ) : ( + parts.object + ); + return ( | null; profiles?: UserProfileLookup; setup: Extract[]; - systemPrompt: Extract | null; user: Extract; }) { return ( @@ -594,7 +678,6 @@ function TurnPromptBlock({ item={user} profiles={profiles} setup={setup} - systemPrompt={systemPrompt} />
); @@ -605,18 +688,16 @@ function PromptUserMessage({ item, profiles, setup = [], - systemPrompt = null, }: { context?: Extract | null; item: Extract; profiles?: UserProfileLookup; setup?: Extract[]; - systemPrompt?: Extract | null; }) { const [contextOpen, setContextOpen] = React.useState(false); const contextSections = React.useMemo( - () => [...(systemPrompt?.sections ?? []), ...(context?.sections ?? [])], - [context, systemPrompt], + () => [...(context?.sections ?? [])], + [context], ); return ( @@ -849,6 +930,50 @@ function TurnSetupStatus({ ); } +/** + * Horizontal rule rendered between session runs in the observer transcript. + * + * Three label states (based on live-frame observation, not harness affinity): + * - `"current"` — most recent session observed via the live relay subscription. + * - `"most-recent"` — newest visible session with no matching live frames + * (loaded from archive or session ended before observation). + * - `"earlier"` — an older session preceding the most-recent one. + */ +function SessionBoundaryDivider({ + labelState, + sessionStartTimestamp, +}: { + labelState: "current" | "most-recent" | "earlier"; + sessionStartTimestamp: string; +}) { + const label = + labelState === "current" + ? "Latest live-observed session" + : labelState === "most-recent" + ? "Most recent observed session" + : "Earlier observed session"; + const formattedDate = new Date(sessionStartTimestamp).toLocaleString(); + return ( +
+
+ + {labelState === "current" ? ( + +
+
+ ); +} + const TranscriptItemView = React.memo(function TranscriptItemView({ agentAvatarUrl, agentName, diff --git a/desktop/src/features/agents/ui/AgentSnapshotExportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotExportDialog.tsx new file mode 100644 index 0000000000..724e7d8f4c --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSnapshotExportDialog.tsx @@ -0,0 +1,203 @@ +import * as React from "react"; +import { AlertCircle, Brain, Download, FileType2 } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import type { + SnapshotFormat, + SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { SnapshotOptionMenu } from "./SnapshotOptionMenu"; + +type AgentSnapshotExportDialogProps = { + agentName: string; + isSavePending: boolean; + open: boolean; + /** Pubkey of the linked agent instance to use as the memory source. + * When null, memory levels are disabled — the definition has no agent + * instance with a keypair to read memory from. */ + linkedAgentPubkey: string | null; + onSaveFile: ( + memoryLevel: SnapshotMemoryLevel, + format: SnapshotFormat, + ) => void; + onOpenChange: (open: boolean) => void; +}; + +const MEMORY_LEVELS: { + value: SnapshotMemoryLevel; + label: string; +}[] = [ + { + value: "none", + label: "Agent only", + }, + { + value: "core", + label: "Agent + core memory", + }, + { + value: "everything", + label: "Agent + all memories", + }, +]; + +const FORMAT_OPTIONS: { value: SnapshotFormat; label: string }[] = [ + { value: "json", label: "JSON" }, + { value: "png", label: "PNG" }, +]; + +const MODAL_RESIZE_TRANSITION = { + duration: 0.22, + ease: [0.23, 1, 0.32, 1], +} as const; + +export function AgentSnapshotExportDialog({ + agentName, + isSavePending, + open, + linkedAgentPubkey, + onSaveFile, + onOpenChange, +}: AgentSnapshotExportDialogProps) { + const [memoryLevel, setMemoryLevel] = + React.useState("none"); + const [format, setFormat] = React.useState("png"); + const shouldReduceMotion = useReducedMotion(); + + const hasLinkedAgent = linkedAgentPubkey !== null; + const showMemoryWarning = memoryLevel !== "none"; + const modalResizeTransition = shouldReduceMotion + ? { duration: 0 } + : MODAL_RESIZE_TRANSITION; + + // Reset state when the dialog opens for a fresh export. + React.useEffect(() => { + if (open) { + setMemoryLevel("none"); + setFormat("png"); + } + }, [open]); + + const isPending = isSavePending; + + return ( + + + + Export {agentName} + + +
+
+
+ + + Memories + + {hasLinkedAgent ? ( + + setMemoryLevel(value as SnapshotMemoryLevel) + } + options={MEMORY_LEVELS} + testId="agent-snapshot-memory-trigger" + value={memoryLevel} + /> + ) : ( + + Agent only + + )} +
+ +
+ + + File format + + setFormat(value as SnapshotFormat)} + options={FORMAT_OPTIONS} + testId="agent-snapshot-format-trigger" + value={format} + /> +
+
+ + + {showMemoryWarning ? ( + +
+ +

+ Memory is stored as plaintext in the + snapshot. Only share it with people you trust. +

+
+
+ ) : null} +
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx new file mode 100644 index 0000000000..ad1219310d --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx @@ -0,0 +1,310 @@ +import * as React from "react"; +import { AlertCircle, Upload } from "lucide-react"; + +import type { + AgentSnapshotImportPreview, + AgentSnapshotImportResult, +} from "@/features/agents/hooks"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Separator } from "@/shared/ui/separator"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type ImportPhase = "preview" | "confirming" | "result"; + +type AgentSnapshotImportDialogProps = { + open: boolean; + /** Preview data loaded by the caller before opening. */ + preview: AgentSnapshotImportPreview; + /** True while the confirm mutation is in-flight. */ + isConfirming: boolean; + /** Set when the confirm mutation has returned a result. */ + result: AgentSnapshotImportResult | null; + /** Error from the confirm mutation, if any. */ + confirmError: string | null; + /** Called with keepAllowlist when user clicks Import. */ + onConfirm: (keepAllowlist: boolean) => void; + onOpenChange: (open: boolean) => void; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function AgentSnapshotImportDialog({ + open, + preview, + isConfirming, + result, + confirmError, + onConfirm, + onOpenChange, +}: AgentSnapshotImportDialogProps) { + // Default: clear the source allowlist (safe default per spec). + const [keepAllowlist, setKeepAllowlist] = React.useState(false); + + // Reset choice whenever the dialog opens with new data. + React.useEffect(() => { + if (open) { + setKeepAllowlist(false); + } + }, [open]); + + const phase: ImportPhase = + result !== null ? "result" : isConfirming ? "confirming" : "preview"; + + const hasMemory = preview.memoryEntryCount > 0; + const memoryLevelLabel = + preview.memoryLevel === "core" + ? "core" + : preview.memoryLevel === "everything" + ? "all" + : "none"; + + return ( + + + +
+ + {phase === "result" ? "Agent imported" : "Import agent snapshot"} + +
+ {phase === "preview" ? ( + <> + + + + + + ) : ( + + + + )} +
+
+
+ + + + {phase === "preview" ? ( + + ) : phase === "confirming" ? ( +
+ Creating agent… +
+ ) : result !== null ? ( + + ) : null} +
+
+ ); +} + +// ── Preview body ────────────────────────────────────────────────────────────── + +function PreviewBody({ + preview, + hasMemory, + memoryLevelLabel, + keepAllowlist, + onKeepAllowlistChange, +}: { + preview: AgentSnapshotImportPreview; + hasMemory: boolean; + memoryLevelLabel: string; + keepAllowlist: boolean; + onKeepAllowlistChange: (v: boolean) => void; +}) { + return ( +
+ {/* Agent identity */} +
+

{preview.displayName}

+ {preview.systemPrompt ? ( +

+ {preview.systemPrompt} +

+ ) : null} +
+ +

+ A new agent will be created with a fresh keypair. The imported agent is + independent of the source — identity never travels. +

+ + {/* Memory section */} + {hasMemory ? ( +
+ +

+ This snapshot includes{" "} + + {preview.memoryEntryCount} {memoryLevelLabel} memory entr + {preview.memoryEntryCount === 1 ? "y" : "ies"} + + . Memory is stored as plaintext in the file and will be restored + under the new agent's identity. +

+
+ ) : ( +

+ No memory included — config only. +

+ )} + + {/* Allowlist section */} + {preview.hasSourceAllowlist ? ( +
+

+ Respond-to allowlist ({preview.sourceAllowlistCount} entr + {preview.sourceAllowlistCount === 1 ? "y" : "ies"}) +

+

+ This snapshot includes a source-environment pubkey allowlist. Those + identities are not meaningful on your relay. +

+
+ + +
+
+ ) : null} +
+ ); +} + +// ── Result body ─────────────────────────────────────────────────────────────── + +export function ResultBody({ + result, + confirmError, +}: { + result: AgentSnapshotImportResult; + confirmError: string | null; +}) { + const hasPartialMemory = + result.memoryTotal > 0 && result.memoryWritten < result.memoryTotal; + + return ( +
+

+ {result.displayName} was created + successfully. +

+ + {result.memoryTotal > 0 ? ( + hasPartialMemory ? ( +
+ +
+

+ Memory partially restored: {result.memoryWritten} of{" "} + {result.memoryTotal} entr + {result.memoryTotal === 1 ? "y" : "ies"} written. The agent + exists but some memory entries failed to publish. +

+ {result.memoryErrors.length > 0 ? ( +
    + {result.memoryErrors.map((err) => ( +
  • + {err} +
  • + ))} +
+ ) : null} +
+
+ ) : ( +

+ {result.memoryTotal} memory entr + {result.memoryTotal === 1 ? "y" : "ies"} restored. +

+ ) + ) : null} + + {result.profileSyncError ? ( +

+ Profile sync: {result.profileSyncError} +

+ ) : null} + + {confirmError ? ( +

{confirmError}

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index d893475c54..67a1f5e957 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,34 +1,54 @@ import * as React from "react"; import { - consumePendingOpenCreateAgent, - subscribeOpenCreateAgent, -} from "@/features/agents/openCreateAgentEvent"; + consumePendingSnapshotImport, + subscribeSnapshotImport, +} from "@/features/agents/openSnapshotImportFromUrlEvent"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; -import { BatchImportDialog } from "./BatchImportDialog"; -import { CreateAgentDialog } from "./CreateAgentDialog"; +import { AgentDialog } from "./AgentDialog"; import { PersonaCatalogDialog } from "./PersonaCatalogDialog"; -import { PersonaDialog } from "./PersonaDialog"; import { PersonaDeleteDialog } from "./PersonaDeleteDialog"; -import { PersonaImportUpdateDialog } from "./PersonaImportUpdateDialog"; import { PersonaShareDialog } from "./PersonaShareDialog"; +import { AgentSnapshotExportDialog } from "./AgentSnapshotExportDialog"; +import { AgentSnapshotImportDialog } from "./AgentSnapshotImportDialog"; +import { TeamSnapshotExportDialog } from "./TeamSnapshotExportDialog"; +import { TeamSnapshotImportDialog } from "./TeamSnapshotImportDialog"; +import { TeamShareDialog } from "./TeamShareDialog"; import { RelayDirectorySection } from "./RelayDirectorySection"; import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; -import { TeamImportDialog } from "./TeamImportDialog"; -import { TeamImportUpdateDialog } from "./TeamImportUpdateDialog"; import { TeamsSection } from "./TeamsSection"; import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useBakedBuildEnvQuery } from "@/features/agents/hooks"; +import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; +import { PageHeader } from "@/shared/ui/PageHeader"; +import { formatAiDefaultsSummary } from "./AgentAiDefaults"; +import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; export function AgentsView() { const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel(); + const { goSettings } = useAppNavigation(); + const { globalConfig } = useGlobalAgentConfig(); + const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: true }); + const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv); + const aiDefaultsSummary = formatAiDefaultsSummary(inheritedDefaults); const agents = useManagedAgentActions(); const personas = usePersonaActions(); + const teamImportInputRef = React.useRef(null); + // Exclusivity: create never sets `personaDialogState` (edit/dup/import do), + // so the create-mode and definition-edit AgentDialog mounts never coexist. + const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false); + + function openUnifiedCreate() { + personas.prepareCreate(); + setIsCreateDialogOpen(true); + } const teamActions = useTeamActions( { setActionNoticeMessage: agents.setActionNoticeMessage, @@ -43,27 +63,52 @@ export function AgentsView() { const isActionPending = agents.isPending || personas.isPending || - teamActions.exportTeamJsonMutation.isPending || teamActions.createTeamMutation.isPending || teamActions.updateTeamMutation.isPending || teamActions.deleteTeamMutation.isPending; + // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable React.useEffect(() => { - if (consumePendingOpenCreateAgent()) { - agents.setIsCreateOpen(true); + // Consume a snapshot import that was enqueued before navigation (e.g. from + // a timeline AgentSnapshotCard click that navigated here). + const pending = consumePendingSnapshotImport(); + if (pending) { + if (pending.snapshotKind === "team") { + void teamActions.handleImportTeamSnapshotFile( + pending.fileBytes, + pending.fileName, + ); + } else { + void personas.handleImportSnapshotFile( + pending.fileBytes, + pending.fileName, + ); + } } - return subscribeOpenCreateAgent(() => { - agents.setIsCreateOpen(true); + return subscribeSnapshotImport(({ fileBytes, fileName, snapshotKind }) => { + if (snapshotKind === "team") { + void teamActions.handleImportTeamSnapshotFile(fileBytes, fileName); + } else { + void personas.handleImportSnapshotFile(fileBytes, fileName); + } }); - }, [agents.setIsCreateOpen]); + }, []); return ( <>
+
{ + void goSettings("agents"); + }} actionErrorMessage={agents.actionErrorMessage} actionNoticeMessage={agents.actionNoticeMessage} agents={agents.managedAgents} @@ -76,15 +121,9 @@ export function AgentsView() { isAgentsLoading={agents.managedAgentsQuery.isLoading} startingAgentPubkey={agents.startingAgentPubkey} startingPersonaIds={agents.startingPersonaIds} - onBulkRemoveStopped={() => { - void agents.handleBulkRemoveStopped(); - }} onBulkStopRunning={() => { void agents.handleBulkStopRunning(); }} - onCreateAgent={() => { - agents.setIsCreateOpen(true); - }} onOpenAgentProfile={(pubkey, options) => { openProfilePanel?.(pubkey, options); }} @@ -117,7 +156,9 @@ export function AgentsView() { } isPersonasLoading={personas.personasQuery.isLoading} isPersonasPending={personas.isPending} - onCreatePersona={personas.openCreate} + onCreatePersona={() => { + openUnifiedCreate(); + }} onChooseCatalog={personas.openCatalog} onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} @@ -126,8 +167,8 @@ export function AgentsView() { void personas.handleSetActive(persona, false, "library"); }} onDeletePersona={personas.openDelete} - onImportPersonaFile={(fileBytes, fileName) => { - void personas.handleImportFile(fileBytes, fileName); + onImportSnapshotFile={(fileBytes, fileName) => { + void personas.handleImportSnapshotFile(fileBytes, fileName); }} /> @@ -147,12 +188,11 @@ export function AgentsView() { onDelete={teamActions.setTeamToDelete} onDuplicate={teamActions.openDuplicateDialog} onEdit={teamActions.openEditDialog} - onExport={teamActions.handleExportTeam} - onImportFile={teamActions.handleImportFile} - onInstallFromDirectory={teamActions.handleInstallFromDirectory} - onSync={teamActions.handleSyncTeam} - onRevealInFinder={teamActions.handleRevealInFinder} onAddToChannel={teamActions.setTeamToAddToChannel} + onShare={teamActions.openShare} + onImport={() => { + teamImportInputRef.current?.click(); + }} personas={personas.libraryPersonas} teams={teamActions.teams} /> @@ -171,14 +211,21 @@ export function AgentsView() {
- {agents.isCreateOpen ? ( - { - agents.setLogAgentPubkey(result.agent.pubkey); - agents.setCreatedAgent(result); + {isCreateDialogOpen ? ( + { + if (!open) setIsCreateDialogOpen(false); }} - onOpenChange={agents.setIsCreateOpen} - open={agents.isCreateOpen} + onSubmitDefinition={personas.handleSubmit} + runtimes={personas.acpRuntimesQuery.data ?? []} + runtimesLoading={personas.acpRuntimesQuery.isLoading} /> ) : null} {agents.agentToAddToChannel ? ( @@ -207,14 +254,12 @@ export function AgentsView() { { - if (!open) { - personas.setCreatedAgent(null); - } + if (!open) personas.dismissCreatedAgent(); }} /> ) : null} {personas.personaDialogState ? ( - { if (!open) { personas.setPersonaDialogState(null); @@ -246,6 +286,11 @@ export function AgentsView() { ) : null} {personas.personaToDelete ? ( a.personaId === personas.personaToDelete?.id, + ).length + } onConfirm={(persona) => { void personas.handleDelete(persona); }} @@ -260,23 +305,13 @@ export function AgentsView() { ) : null} {personas.personaToShare ? ( { - if (personas.personaToShare) { - personas.setPersonaCatalogVisibility( - personas.personaToShare, - visible, - ); - } - }} + linkedAgentPubkey={personas.personaToShare.linkedAgentPubkey} onExport={() => { - if (personas.personaToShare) { - personas.handleExport(personas.personaToShare); - } + const shareTarget = personas.personaToShare; + if (!shareTarget) return; + personas.setPersonaToShare(null); + personas.setPersonaToExportSnapshot(shareTarget); }} onOpenChange={(open) => { if (!open) { @@ -284,7 +319,47 @@ export function AgentsView() { } }} open={personas.personaToShare !== null} - persona={personas.personaToShare} + persona={personas.personaToShare.persona} + /> + ) : null} + {personas.personaToExportSnapshot ? ( + { + if (personas.personaToExportSnapshot) { + personas.handleExportSnapshot( + personas.personaToExportSnapshot.persona, + personas.personaToExportSnapshot.linkedAgentPubkey, + memoryLevel, + format, + ); + } + }} + onOpenChange={(open) => { + if (!open) { + personas.setPersonaToExportSnapshot(null); + } + }} + /> + ) : null} + {personas.snapshotImportState ? ( + { + void personas.handleConfirmSnapshotImport(keepAllowlist); + }} + onOpenChange={(open) => { + if (!open) { + personas.closeSnapshotImportDialog(); + } + }} /> ) : null} {personas.isCatalogDialogOpen ? ( @@ -328,12 +403,10 @@ export function AgentsView() { : null } initialValues={teamActions.teamDialogState.initialValues} - isImportPending={teamActions.isApplyingTeamImportUpdate} isPending={ teamActions.createTeamMutation.isPending || teamActions.updateTeamMutation.isPending } - onImportUpdateFile={teamActions.handleEditDialogImportUpdateFile} onOpenChange={(open) => { if (!open) { teamActions.setTeamDialogState(null); @@ -374,79 +447,88 @@ export function AgentsView() { team={teamActions.teamToAddToChannel} /> ) : null} - {personas.batchImportResult ? ( - { - if (!open) { - personas.setBatchImportResult(null); - } - }} - open={personas.batchImportResult !== null} - result={personas.batchImportResult} - /> - ) : null} - {teamActions.teamImportPreview ? ( - { - if (!open) { - teamActions.setTeamImportPreview(null); - } - }} - open={teamActions.teamImportPreview !== null} - preview={teamActions.teamImportPreview.preview} - /> - ) : null} - {teamActions.teamImportTarget ? ( - { - if (!open) { - teamActions.closeImportUpdateDialog(); + onExport={() => { + if (teamActions.teamToShare) { + const team = teamActions.teamToShare; + teamActions.setTeamToShare(null); + teamActions.openExportSnapshot(team); } }} - open={teamActions.teamImportTarget !== null} - personas={personas.libraryPersonas} - preview={teamActions.teamImportTargetPreview?.preview ?? null} - team={teamActions.teamImportTarget} - /> - ) : null} - {personas.personaImportActions.personaImportTarget ? ( - { if (!open) { - personas.personaImportActions.closeImportUpdateDialog(); + teamActions.setTeamToShare(null); } }} - open={personas.personaImportActions.personaImportTarget !== null} - persona={personas.personaImportActions.personaImportTarget} - preview={ - personas.personaImportActions.personaImportTargetPreview?.preview ?? - null - } + open={teamActions.teamToShare !== null} + team={teamActions.teamToShare} /> ) : null} + {teamActions.teamToExport ? ( + { + if (teamActions.teamToExport) { + teamActions.handleExportTeamSnapshot( + teamActions.teamToExport, + memoryLevel, + format, + ); + } + }} + onOpenChange={(open) => { + if (!open) { + teamActions.setTeamToExport(null); + } + }} + /> + ) : null} + {teamActions.teamSnapshotImportState ? ( + { + void teamActions.handleConfirmTeamSnapshotImport(keepAllowlist); + }} + onOpenChange={(open) => { + if (!open) { + teamActions.closeTeamSnapshotImportDialog(); + } + }} + /> + ) : null} + {/* Hidden file input for team snapshot import via file picker */} + { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const buffer = reader.result as ArrayBuffer; + const fileBytes = Array.from(new Uint8Array(buffer)); + void teamActions.handleImportTeamSnapshotFile(fileBytes, file.name); + }; + reader.readAsArrayBuffer(file); + // Reset so the same file can be picked again. + e.target.value = ""; + }} + /> ); } diff --git a/desktop/src/features/agents/ui/BatchImportDialog.tsx b/desktop/src/features/agents/ui/BatchImportDialog.tsx deleted file mode 100644 index 1644bd8e01..0000000000 --- a/desktop/src/features/agents/ui/BatchImportDialog.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import * as React from "react"; -import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; - -import { - ImportStatusIcon, - type ImportItemStatus, -} from "@/shared/ui/import-status-icon"; - -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; -import { createPersona } from "@/shared/api/tauriPersonas"; -import { Button } from "@/shared/ui/button"; -import { Checkbox } from "@/shared/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { buildBatchImportPersonaInput } from "./batchImportPersonaInput"; -import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar"; -import { importedAvatarUrl } from "./personaDialogState"; - -type BatchImportDialogProps = { - fileName: string; - open: boolean; - result: ParsePersonaFilesResult | null; - onOpenChange: (open: boolean) => void; - onComplete: (count: number) => void; -}; - -export function BatchImportDialog({ - fileName, - open, - result, - onOpenChange, - onComplete, -}: BatchImportDialogProps) { - const [selected, setSelected] = React.useState>(new Set()); - const [status, setStatus] = React.useState< - "idle" | "importing" | "done" | "error" - >("idle"); - const [importedCount, setImportedCount] = React.useState(0); - const [itemStatuses, setItemStatuses] = React.useState< - Map - >(new Map()); - const [errorMessage, setErrorMessage] = React.useState(null); - const [expandedIndex, setExpandedIndex] = React.useState(null); - const [skippedExpanded, setSkippedExpanded] = React.useState(false); - - React.useEffect(() => { - if (!open) { - return; - } - - const count = result?.personas.length ?? 0; - setSelected(new Set(Array.from({ length: count }, (_, i) => i))); - setStatus("idle"); - setImportedCount(0); - setItemStatuses(new Map()); - setErrorMessage(null); - setExpandedIndex(null); - setSkippedExpanded(false); - }, [open, result]); - - async function handleImport() { - if (!result) { - return; - } - - setStatus("importing"); - setErrorMessage(null); - - // Initialize all selected items as pending - const initialStatuses = new Map(); - for (const index of selected) { - initialStatuses.set(index, "pending"); - } - setItemStatuses(new Map(initialStatuses)); - - let completed = 0; - - for (const index of selected) { - const persona = result.personas[index]; - if (!persona) { - continue; - } - - setItemStatuses((prev) => { - const next = new Map(prev); - next.set(index, "importing"); - return next; - }); - - try { - const input = buildBatchImportPersonaInput(persona); - const avatarUrl = await resolveManagedAgentAvatarUrl(input.avatarUrl); - await createPersona({ ...input, avatarUrl }); - completed += 1; - setImportedCount(completed); - setItemStatuses((prev) => { - const next = new Map(prev); - next.set(index, "done"); - return next; - }); - } catch (error) { - setItemStatuses((prev) => { - const next = new Map(prev); - next.set(index, "error"); - return next; - }); - setStatus("error"); - setErrorMessage( - `Imported ${completed} of ${selected.size}. Failed on '${persona.displayName}': ${error instanceof Error ? error.message : String(error)}. Already-imported personas are saved.`, - ); - return; - } - } - - setStatus("done"); - onComplete(completed); - } - - const personas = result?.personas ?? []; - const skipped = result?.skipped ?? []; - const selectedCount = selected.size; - - return ( - - -
- - Import Personas - - Found {personas.length} persona - {personas.length !== 1 ? "s" : ""} in {fileName || "archive"}. - - - -
-
- {personas.map((persona, index) => { - const isExpanded = expandedIndex === index; - const isSelected = selected.has(index); - const firstLine = persona.systemPrompt - .trim() - .split("\n") - .find((line) => line.trim().length > 0); - const avatarUrl = importedAvatarUrl(persona); - - return ( -
- - {isExpanded ? ( -
-
-                          {persona.systemPrompt}
-                        
-
- ) : null} -
- ); - })} -
- - {skipped.length > 0 ? ( -
- - {skippedExpanded ? ( -
- {skipped.map((file) => ( -

- {file.sourceFile} - {" — "} - {file.reason} -

- ))} -
- ) : null} -
- ) : null} - - {errorMessage ? ( -

- {errorMessage} -

- ) : null} -
- -
- - -
-
-
-
- ); -} diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx deleted file mode 100644 index de792e35dc..0000000000 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ /dev/null @@ -1,689 +0,0 @@ -import { AlertTriangle, ChevronDown } from "lucide-react"; -import * as React from "react"; - -import { - useAcpRuntimesQuery, - useAvailableAcpRuntimes, - useBackendProvidersQuery, - useCreateManagedAgentMutation, - useManagedAgentPrereqsQuery, -} from "@/features/agents/hooks"; -import { probeBackendProvider } from "@/shared/api/tauri"; -import type { - BackendProviderProbeResult, - CreateManagedAgentInput, - CreateManagedAgentResponse, - RespondToMode, -} from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { - CreateAgentBasicsFields, - CreateAgentOptionToggles, - CreateAgentRuntimeField, - CreateAgentRuntimeFields, -} from "./CreateAgentDialogSections"; -import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; -import { - coerceConfigValues, - ProviderConfigFields, -} from "./ProviderConfigFields"; -import { CreateAgentRespondToField } from "./RespondToField"; -import { RelayMeshAgentSection } from "@/features/mesh-compute/ui/RelayMeshAgentSection"; -import { meshPrepareRelayMeshClient } from "@/shared/api/tauriMesh"; -import type { MeshServeTarget } from "@/shared/api/tauriMesh"; -import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; - -export function CreateAgentDialog({ - open, - onCreated, - onOpenChange, -}: { - open: boolean; - onCreated: (result: CreateManagedAgentResponse) => void; - onOpenChange: (open: boolean) => void; -}) { - const createMutation = useCreateManagedAgentMutation(); - const providersQuery = useAvailableAcpRuntimes({ enabled: open }); - const allProvidersQuery = useAcpRuntimesQuery({ enabled: open }); - const backendProvidersQuery = useBackendProvidersQuery({ enabled: open }); - const { lastRuntimeId, setLastRuntime } = useLastRuntime(); - const [acpCommand, setAcpCommand] = React.useState("buzz-acp"); - const [agentCommand, setAgentCommand] = React.useState("buzz-agent"); - const [agentArgs, setAgentArgs] = React.useState("acp"); - const [mcpCommand, setMcpCommand] = React.useState(""); - const [mcpToolsets, setMcpToolsets] = React.useState(""); - const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand, { - enabled: open, - }); - const [name, setName] = React.useState(""); - const [relayUrl, setRelayUrl] = React.useState(""); - const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true); - const [startOnAppLaunch, setStartOnAppLaunch] = React.useState(true); - const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("320"); - const [parallelism, setParallelism] = React.useState("24"); - const [systemPrompt, setSystemPrompt] = React.useState(""); - const [envVars, setEnvVars] = React.useState({}); - const [selectedRuntimeId, setSelectedRuntimeId] = - React.useState("custom"); - const [hasSyncedProviderSelection, setHasSyncedProviderSelection] = - React.useState(false); - const [showAdvanced, setShowAdvanced] = React.useState(false); - const [respondTo, setRespondTo] = React.useState("owner-only"); - const [respondToAllowlist, setRespondToAllowlist] = React.useState( - [], - ); - - const [runOn, setRunOn] = React.useState<"local" | string>("local"); - const [providerConfig, setProviderConfig] = React.useState< - Record - >({}); - const [probedProvider, setProbedProvider] = - React.useState(null); - const [probeError, setProbeError] = React.useState(null); - - // When `useMesh` is on, the agent runs buzz-agent against a member's - // shared compute. The ACP runtime + backend selectors are hidden; runtime - // fields are driven by `mesh_agent_preset(meshModelId)` and the submit - // input carries `model: meshModelId`. - const [useMesh, setUseMesh] = React.useState(false); - const [meshModelId, setMeshModelId] = React.useState(""); - const [meshTarget, setMeshTarget] = React.useState( - null, - ); - const [meshClientError, setMeshClientError] = React.useState( - null, - ); - // True while the relay-mesh client preflight (connect-request + dial) runs - // inside handleSubmit. That await can take tens of seconds and happens - // before createMutation fires, so isPending alone leaves the button live. - const [meshPreparing, setMeshPreparing] = React.useState(false); - - const runtimes = providersQuery.data ?? []; - const allProviders = allProvidersQuery.data ?? []; - const unavailableCount = allProviders.filter( - (p) => p.availability !== "available", - ).length; - const backendProviders = backendProvidersQuery.data ?? []; - const prereqs = prereqsQuery.data ?? null; - const selectedRuntime = React.useMemo( - () => runtimes.find((runtime) => runtime.id === selectedRuntimeId) ?? null, - [runtimes, selectedRuntimeId], - ); - const selectedBackendProvider = React.useMemo( - () => backendProviders.find((p) => p.id === runOn) ?? null, - [backendProviders, runOn], - ); - // Relay mesh always runs in local mode (buzz-agent + OpenAI-compat env); - // when on, it suppresses the backend "Run on" branch even if a stale - // `runOn` value remains. The relay-mesh path is its own thing. - const isProviderMode = !useMesh && runOn !== "local"; - - const isSpawnSupported = - prereqs?.acp.available === true && prereqs?.mcp.available === true; - const spawnToggleDisabled = - prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported); - const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading; - - React.useEffect(() => { - if (hasSyncedProviderSelection || providersQuery.isLoading) { - return; - } - - // Prefer last-used runtime from localStorage - const remembered = lastRuntimeId - ? runtimes.find((runtime) => runtime.id === lastRuntimeId) - : null; - if (remembered) { - setSelectedRuntimeId(remembered.id); - setAgentCommand(remembered.command); - setAgentArgs(remembered.defaultArgs.join(",")); - setMcpCommand(remembered.mcpCommand ?? ""); - } else { - const matchingProvider = - runtimes.find((runtime) => runtime.command === agentCommand) ?? null; - if (matchingProvider) { - setSelectedRuntimeId(matchingProvider.id); - } - } - setHasSyncedProviderSelection(true); - }, [ - agentCommand, - hasSyncedProviderSelection, - lastRuntimeId, - runtimes, - providersQuery.isLoading, - ]); - - React.useEffect(() => { - if ( - !prereqs || - (prereqs.acp.available && prereqs.mcp.available) || - !spawnAfterCreate - ) { - return; - } - - setSpawnAfterCreate(false); - }, [prereqs, spawnAfterCreate]); - - React.useEffect(() => { - if ( - providersQuery.error instanceof Error || - prereqsQuery.error instanceof Error - ) { - setShowAdvanced(true); - } - }, [prereqsQuery.error, providersQuery.error]); - - // Probe the backend provider when runOn changes to a non-local value - React.useEffect(() => { - if (!isProviderMode || !selectedBackendProvider) { - setProbedProvider(null); - setProbeError(null); - return; - } - - let cancelled = false; - setProbeError(null); - setProbedProvider(null); - - probeBackendProvider(selectedBackendProvider.binaryPath) - .then((result) => { - if (!cancelled) { - setProbedProvider(result); - // Initialize config from schema defaults so unchanged defaults - // are included in the submit payload (not silently dropped). - if (result.config_schema) { - const props = - (result.config_schema as Record)?.properties ?? - {}; - const defaults: Record = {}; - for (const [key, prop] of Object.entries(props) as [ - string, - Record, - ][]) { - if (prop.default != null) { - defaults[key] = String(prop.default); - } - } - setProviderConfig(defaults); - } - } - }) - .catch((err: unknown) => { - if (!cancelled) { - setProbeError(err instanceof Error ? err.message : String(err)); - } - }); - - return () => { - cancelled = true; - }; - }, [isProviderMode, selectedBackendProvider]); - - function reset() { - setName(""); - setRelayUrl(""); - setSpawnAfterCreate(true); - setStartOnAppLaunch(true); - setAcpCommand("buzz-acp"); - setAgentCommand("buzz-agent"); - setAgentArgs("acp"); - setMcpCommand(""); - setMcpToolsets(""); - setTurnTimeoutSeconds("320"); - setParallelism("24"); - setSystemPrompt(""); - setEnvVars({}); - setSelectedRuntimeId("custom"); - setHasSyncedProviderSelection(false); - setShowAdvanced(false); - setRunOn("local"); - setProviderConfig({}); - setProbedProvider(null); - setProbeError(null); - setUseMesh(false); - setMeshModelId(""); - setMeshClientError(null); - setRespondTo("owner-only"); - setRespondToAllowlist([]); - createMutation.reset(); - } - - function handleOpenChange(next: boolean) { - if (!next) { - reset(); - } - - onOpenChange(next); - } - - function handleProviderChange(nextProviderId: string) { - setSelectedRuntimeId(nextProviderId); - - if (nextProviderId === "custom") { - setShowAdvanced(true); - return; - } - - const provider = runtimes.find( - (candidate) => candidate.id === nextProviderId, - ); - if (!provider) { - return; - } - - setLastRuntime(nextProviderId); - setAgentCommand(provider.command); - setAgentArgs(provider.defaultArgs.join(",")); - setMcpCommand(provider.mcpCommand ?? ""); - } - - function handleRunOnChange(value: string) { - setRunOn(value); - setProviderConfig({}); - setProbedProvider(null); - setProbeError(null); - } - - // Check provider config required fields are filled. - const providerConfigComplete = React.useMemo(() => { - if (!isProviderMode || !probedProvider?.config_schema) return true; - const schema = probedProvider.config_schema as Record; - const required: string[] = (schema?.required as string[] | undefined) ?? []; - return required.every( - (key) => (providerConfig[key] ?? "").trim().length > 0, - ); - }, [isProviderMode, probedProvider, providerConfig]); - - // Allowlist mode requires at least one entry, mirroring the harness's own - // validation. If we let it through empty, the agent crash-loops at startup - // with a config error. - const respondToValid = - respondTo !== "allowlist" || respondToAllowlist.length > 0; - - const canSubmit = - name.trim().length > 0 && - !isDiscoveryPending && - !( - !isProviderMode && - spawnAfterCreate && - prereqs !== null && - !isSpawnSupported - ) && - // Block submission until probe succeeds in provider mode — required - // fields and config schema are only known after a successful probe. - !(isProviderMode && !probedProvider) && - providerConfigComplete && - // Relay-mesh mode requires a concrete serve target, not just a model name. - !(useMesh && (meshModelId.trim().length === 0 || meshTarget == null)) && - respondToValid && - !meshPreparing && - !createMutation.isPending; - - async function handleSubmit() { - setMeshClientError(null); - try { - if (useMesh) { - try { - if (!meshTarget) { - setMeshClientError( - "Select a relay mesh serve target before creating the agent.", - ); - return; - } - setMeshPreparing(true); - try { - await meshPrepareRelayMeshClient(meshModelId.trim(), meshTarget); - } finally { - setMeshPreparing(false); - } - } catch (err) { - setMeshClientError(err instanceof Error ? err.message : String(err)); - return; - } - } - - // Only send the allowlist when the mode is actually "allowlist". - // Other modes ignore it server-side, but keeping the wire clean makes - // the agent record easier to inspect. - const respondToFields = { - respondTo, - respondToAllowlist: - respondTo === "allowlist" ? respondToAllowlist : undefined, - } as const; - - const input: CreateManagedAgentInput = isProviderMode - ? { - name: name.trim(), - relayUrl: relayUrl.trim() || undefined, - turnTimeoutSeconds: - Number.parseInt(turnTimeoutSeconds, 10) > 0 - ? Number.parseInt(turnTimeoutSeconds, 10) - : undefined, - parallelism: - Number.parseInt(parallelism, 10) > 0 - ? Number.parseInt(parallelism, 10) - : undefined, - systemPrompt: systemPrompt.trim() || undefined, - envVars, - spawnAfterCreate: true, - startOnAppLaunch: false, // Remote agents don't auto-start with the desktop - backend: { - type: "provider", - id: runOn, - config: coerceConfigValues( - providerConfig, - probedProvider?.config_schema, - ), - }, - ...respondToFields, - } - : { - name: name.trim(), - relayUrl: relayUrl.trim() || undefined, - acpCommand: acpCommand.trim() || undefined, - agentCommand: agentCommand.trim() || undefined, - agentArgs: agentArgs - .split(",") - .map((value) => value.trim()) - .filter((value) => value.length > 0), - mcpCommand: mcpCommand.trim() || undefined, - mcpToolsets: mcpToolsets.trim() || undefined, - turnTimeoutSeconds: - Number.parseInt(turnTimeoutSeconds, 10) > 0 - ? Number.parseInt(turnTimeoutSeconds, 10) - : undefined, - parallelism: - Number.parseInt(parallelism, 10) > 0 - ? Number.parseInt(parallelism, 10) - : undefined, - systemPrompt: systemPrompt.trim() || undefined, - envVars, - model: useMesh ? meshModelId.trim() || undefined : undefined, - relayMesh: useMesh ? { modelRef: meshModelId.trim() } : undefined, - spawnAfterCreate, - // Relay-mesh agents need a freshly selected serve target to start; - // do not auto-restore them later with only the saved model/env. - startOnAppLaunch: useMesh ? false : startOnAppLaunch, - backend: { type: "local" }, - ...respondToFields, - }; - - const created = await createMutation.mutateAsync(input); - handleOpenChange(false); - onCreated(created); - } catch { - // React Query stores the error; keep the dialog open and render it inline. - } - } - - return ( - - -
- - Create agent - - This creates a local agent identity, syncs its display name when - possible, and can spawn `buzz-acp` immediately. - - - -
- - - v.trim()) - .filter((v) => v.length > 0), - mcpCommand, - model: meshModelId || null, - envVars, - }} - modelId={meshModelId} - targetEndpointAddr={meshTarget?.endpointAddr ?? ""} - onModelIdChange={(nextId, patch) => { - setMeshModelId(nextId); - if (patch == null) return; - // Fan out the preset into the existing setters so the rest - // of the dialog (and the submit branch) see normal local-mode - // values — relay-mesh is a curated local agent. - setAcpCommand(patch.acpCommand); - setAgentCommand(patch.agentCommand); - setAgentArgs(patch.agentArgs.join(",")); - setMcpCommand(patch.mcpCommand); - setEnvVars(patch.envVars); - }} - onTargetChange={setMeshTarget} - onUseMeshChange={(next) => { - setUseMesh(next); - if (!next) { - // Clearing the toggle: drop the model selection so the - // submit guard doesn't fire on a stale value. The runtime - // fields keep whatever the user had — they can re-pick - // ACP runtime or stay with the preset values, their call. - setMeshModelId(""); - setMeshTarget(null); - } - }} - useMesh={useMesh} - /> - - {/* Run on selector — only shown when backend providers are discovered */} - {!useMesh && backendProviders.length > 0 ? ( -
- - -
- ) : null} - - {/* Provider mode: trust warning + config fields */} - {isProviderMode && selectedBackendProvider ? ( -
-
- -

- This provider at{" "} - - {selectedBackendProvider.binaryPath} - {" "} - will receive your agent's private key. Only use - providers from trusted sources. -

-
- - {probeError ? ( -

- Could not probe provider: {probeError} -

- ) : null} - - {probedProvider?.config_schema ? ( - - ) : null} -
- ) : null} - - {/* Local mode: show the ACP runtime selector */} - {!isProviderMode && !useMesh ? ( - - ) : null} - - { - setStartOnAppLaunch((current) => !current); - }} - onToggleSpawnAfterCreate={() => { - if (!spawnToggleDisabled) { - setSpawnAfterCreate((current) => !current); - } - }} - prereqs={prereqs} - startOnAppLaunch={isProviderMode ? false : startOnAppLaunch} - startOnAppLaunchDisabled={isProviderMode} - spawnAfterCreate={isProviderMode ? true : spawnAfterCreate} - spawnToggleDisabled={isProviderMode || spawnToggleDisabled} - /> - - - -
- - - {showAdvanced ? ( -
-
- - -

- Local Buzz binary checks and ACP runtime discovery now - live in Settings > Doctor. -

- - {providersQuery.error instanceof Error ? ( -

- {providersQuery.error.message} -

- ) : null} - - {prereqsQuery.error instanceof Error ? ( -

- {prereqsQuery.error.message} -

- ) : null} -
-
- ) : null} -
- - - - {meshClientError ? ( -

- Mesh client failed to start: {meshClientError} -

- ) : null} - - {createMutation.error instanceof Error ? ( -

- {createMutation.error.message} -

- ) : null} -
- -
- - -
-
-
-
- ); -} diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx deleted file mode 100644 index f8be21c2ef..0000000000 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import type { AcpRuntime, ManagedAgentPrereqs } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { Input } from "@/shared/ui/input"; -import { Textarea } from "@/shared/ui/textarea"; -import { describeResolvedCommand } from "./agentUi"; - -export function CreateAgentBasicsFields({ - name, - onNameChange, -}: { - name: string; - onNameChange: (value: string) => void; -}) { - return ( -
- - onNameChange(event.target.value)} - placeholder="Support bot" - spellCheck={false} - value={name} - /> -

- Used as the local label and synced to the agent profile display name - when the relay accepts the create-time auth. -

-
- ); -} - -export function CreateAgentRuntimeField({ - runtimes, - runtimesLoading, - selectedRuntime, - selectedRuntimeId, - unavailableCount, - onRuntimeChange, -}: { - runtimes: AcpRuntime[]; - runtimesLoading: boolean; - selectedRuntime: AcpRuntime | null; - selectedRuntimeId: string; - unavailableCount: number; - onRuntimeChange: (value: string) => void; -}) { - return ( -
- - - {selectedRuntime ? ( -

- Detected via{" "} - - {describeResolvedCommand( - selectedRuntime.command, - selectedRuntime.binaryPath, - )} - -

- ) : runtimesLoading ? ( -

- Looking for installed ACP runtimes... -

- ) : ( -

- No known ACP runtime was detected. You can still enter a custom - command in Advanced setup. -

- )} - {unavailableCount > 0 ? ( -

- {unavailableCount} additional{" "} - {unavailableCount === 1 ? "runtime" : "runtimes"} available to - install. Visit Settings > Doctor to set{" "} - {unavailableCount === 1 ? "it" : "them"} up. -

- ) : null} -
- ); -} - -export function CreateAgentRuntimeFields({ - acpCommand, - agentArgs, - agentCommand, - mcpCommand, - mcpToolsets, - parallelism, - relayUrl, - selectedRuntimeId, - systemPrompt, - turnTimeoutSeconds, - onAcpCommandChange, - onAgentArgsChange, - onAgentCommandChange, - onMcpCommandChange, - onMcpToolsetsChange, - onParallelismChange, - onRelayUrlChange, - onSystemPromptChange, - onTurnTimeoutChange, -}: { - acpCommand: string; - agentArgs: string; - agentCommand: string; - mcpCommand: string; - mcpToolsets: string; - parallelism: string; - relayUrl: string; - selectedRuntimeId: string; - systemPrompt: string; - turnTimeoutSeconds: string; - onAcpCommandChange: (value: string) => void; - onAgentArgsChange: (value: string) => void; - onAgentCommandChange: (value: string) => void; - onMcpCommandChange: (value: string) => void; - onMcpToolsetsChange: (value: string) => void; - onParallelismChange: (value: string) => void; - onRelayUrlChange: (value: string) => void; - onSystemPromptChange: (value: string) => void; - onTurnTimeoutChange: (value: string) => void; -}) { - return ( - <> -
- {/* Relay URL pins the agent to a specific relay; blank falls back to - the active workspace relay. Shown for all agents. */} -
- - onRelayUrlChange(event.target.value)} - placeholder="Leave blank to use the workspace relay" - value={relayUrl} - /> -

- WebSocket URL of the relay this agent connects to. Leave blank to - use the active workspace relay. -

-
- -
- - onAcpCommandChange(event.target.value)} - value={acpCommand} - /> -

- The buzz-acp binary path or alias used to launch the ACP harness - process. -

-
-
- - {selectedRuntimeId === "custom" ? ( -
- - onAgentCommandChange(event.target.value)} - value={agentCommand} - /> -

- Full path or shell command for the agent binary when no known ACP - runtime was detected. -

-
- ) : null} - -
-
- - onAgentArgsChange(event.target.value)} - placeholder="Comma-separated" - value={agentArgs} - /> -

- buzz-acp splits args on commas, matching the testing guide. -

-
- -
- - onMcpCommandChange(event.target.value)} - value={mcpCommand} - /> -

- Optional. Only needed for agents that use a custom MCP server (e.g. - buzz-agent with buzz-dev-mcp). Leave blank for most agents. -

-
- -
- - onTurnTimeoutChange(event.target.value)} - placeholder="300" - value={turnTimeoutSeconds} - /> -

- Seconds before an agent turn is cancelled. Defaults to 300. -

-
- -
- - onParallelismChange(event.target.value)} - placeholder="1" - step="1" - type="number" - value={parallelism} - /> -

- Number of ACP worker subprocesses. buzz-acp allows 1-32. -

-
-
- -
- - onMcpToolsetsChange(event.target.value)} - placeholder="default,canvas,forums,dms,media" - value={mcpToolsets} - /> -

- Comma-separated list of toolsets to expose via BUZZ_TOOLSETS. - Available: default, channel_admin, dms, canvas, workflow_admin, - identity, forums, social, media. Leave blank for default toolsets - (default, canvas, forums, dms, media). -

-
- -
- -