diff --git a/.env.example b/.env.example index b9bfcada0e..c65a8c19e0 100644 --- a/.env.example +++ b/.env.example @@ -160,6 +160,11 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # Binary for an optional MCP server sidecar (e.g. buzz-dev-mcp for buzz-agent). # BUZZ_ACP_MCP_COMMAND= +# Path to an optional version 1 JSON file defining additional stdio MCP servers. +# This file may contain credentials. Keep it out of Git and restrict it to +# its owner. +# BUZZ_ACP_MCP_CONFIG=/absolute/path/to/mcp-servers.json + # Number of parallel agent subprocesses (1–32). # BUZZ_ACP_AGENTS=1 diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index db34fddc2c..f31d4b835f 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -4,7 +4,7 @@ name: Auto-tag on Release PR Merge # 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 → release.yml (desktop app) +# version-bump/ → tag desktop-v → release.yml (desktop app) # relay-release/ → tag relay-v → docker.yml (relay image) # chart-release/ → tag chart-v → helm-chart.yml (main helm chart) # push-chart-release/ → tag push-chart-v → push-gateway-helm-chart.yml @@ -35,6 +35,11 @@ permissions: jobs: auto-tag: + permissions: + contents: read + pull-requests: read + checks: read + statuses: read if: > github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository @@ -57,7 +62,7 @@ jobs: case "$BRANCH" in version-bump/*) VERSION="${BRANCH#version-bump/}" - TAG_PREFIX="v" ;; + TAG_PREFIX="desktop-v" ;; relay-release/*) VERSION="${BRANCH#relay-release/}" TAG_PREFIX="relay-v" ;; @@ -85,9 +90,33 @@ jobs: { echo "enabled=true" echo "tag=${TAG_PREFIX}${VERSION}" + if [[ "$TAG_PREFIX" == desktop-v ]]; then + echo "target_sha=${{ github.event.pull_request.merge_commit_sha }}" + echo "desktop=true" + else + echo "target_sha=$GITHUB_SHA" + echo "desktop=false" + fi } >> "$GITHUB_OUTPUT" echo "Tagging ${TAG_PREFIX}${VERSION}" + + - name: Verify immutable reviewed desktop candidate + if: steps.release.outputs.desktop == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release.outputs.tag }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + VERSION="${VERSION#desktop-v}" + export VERSION + scripts/verify-desktop-release-merge.sh + - name: Create release tagger token if: steps.release.outputs.enabled == 'true' id: release-tagger @@ -102,21 +131,22 @@ jobs: env: GH_TOKEN: ${{ steps.release-tagger.outputs.token }} TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} run: | set -euo pipefail # Check gh's exit status, not its output. A missing ref returns a 404 # JSON body on stdout, which must not be mistaken for an existing tag. if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" - if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then - echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then + echo "Tag $TAG already exists at $TARGET_SHA — skipping tag creation" exit 0 else - echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)" + echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $TARGET_SHA)" exit 1 fi fi gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ - -f sha="$GITHUB_SHA" \ + -f sha="$TARGET_SHA" \ --silent diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4826d985f..d392170f6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Desktop release candidate contract + run: scripts/test-desktop-release-candidate.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh @@ -690,6 +692,18 @@ jobs: --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Workspace profile (kind:9033) gate tests + # Call-site integration for the 9033 authorization gate: open relay + # rosterless/steward transitions and the closed-relay admin/owner rule, + # against real Postgres. #[ignore]d in the default suite, selected + # explicitly here — see handlers::relay_admin::tests. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/handlers::relay_admin::tests/)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: NIP-ER reminder e2e # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path # validation, author-only read filtering, and scheduler delivery against @@ -702,6 +716,17 @@ jobs: --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 + - name: NIP-MP coordinate deletion guard + # Verifies the never-delete-newer invariant of soft_delete_by_coordinate: + # a stale tombstone (created_at earlier than the live head) spares that + # head, and an equal-timestamp tombstone deletes it. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -737,7 +762,7 @@ jobs: ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture env: diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml new file mode 100644 index 0000000000..eddebea685 --- /dev/null +++ b/.github/workflows/desktop-release-candidate.yml @@ -0,0 +1,26 @@ +name: Desktop Release Candidate + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + name: Desktop Release Candidate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Validate immutable desktop candidate + if: startsWith(github.event.pull_request.head.ref, 'version-bump/') + env: + VERSION: ${{ github.event.pull_request.head.ref }} + run: | + VERSION="${VERSION#version-bump/}" + scripts/desktop_release.py validate --candidate HEAD --version "$VERSION" --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b87e9c8c08..7d5f3fbf40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,13 @@ name: Release +concurrency: + group: desktop-release-${{ github.ref }} + cancel-in-progress: false + on: push: tags: - - 'v[0-9]*' - workflow_dispatch: - inputs: - version: - description: "Semver version matching the v-prefixed dispatch tag" - required: true + - 'desktop-v[0-9]*' jobs: # Shared setup: verify the immutable release tag, determine the version, and @@ -19,23 +18,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write + contents: read outputs: version: ${{ steps.version.outputs.version }} source_sha: ${{ steps.source.outputs.source_sha }} steps: - name: Determine version id: version - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.version }} - run: | - if [[ "$EVENT_NAME" == "push" ]]; then - VERSION="${GITHUB_REF_NAME#v}" - else - VERSION="$INPUT_VERSION" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT" - name: Validate version env: @@ -56,42 +46,9 @@ jobs: env: VERSION: ${{ steps.version.outputs.version }} run: | - scripts/verify-release-ref.sh v "$VERSION" + scripts/verify-release-ref.sh desktop-v "$VERSION" echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT" - - name: Create versioned GitHub release - env: - VERSION: ${{ steps.version.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - RELEASE_SHA=$(git rev-parse HEAD) - NOTES="" - if [[ -f CHANGELOG.md ]]; then - NOTES=$(awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found && !/^\$/" CHANGELOG.md) - fi - if [[ -z "$NOTES" ]]; then - NOTES="Buzz Desktop v${VERSION}" - fi - PRERELEASE_FLAGS=() - if [[ "$VERSION" =~ -(test|alpha|beta|rc)([.-]|$) ]]; then - PRERELEASE_FLAGS=(--prerelease --latest=false) - fi - gh release create "v${VERSION}" \ - --target "$RELEASE_SHA" \ - --title "Buzz Desktop v${VERSION}" \ - --notes "$NOTES" \ - "${PRERELEASE_FLAGS[@]}" - - - name: Create rolling auto-update release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create buzz-desktop-latest \ - --prerelease \ - --title "Buzz Desktop Auto-Update" \ - --notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \ - 2>/dev/null || true - release: name: Release if: github.repository == 'block/buzz' @@ -99,7 +56,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -114,7 +71,7 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -272,13 +229,19 @@ jobs: fi echo "dmg=$DMG" >> "$GITHUB_OUTPUT" - # Find the updater .tar.gz and .sig + # Find the updater .tar.gz and .sig. Give each architecture a unique + # release basename before artifacts are merged by the final writer. ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1) SIG="${ARCHIVE}.sig" if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 fi + RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_aarch64.app.tar.gz" + mv "$ARCHIVE" "$RENAMED" + mv "$SIG" "${RENAMED}.sig" + ARCHIVE="$RENAMED" + SIG="${RENAMED}.sig" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -289,23 +252,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload arm64 DMG to versioned GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.artifacts.outputs.dmg }} - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Apple Silicon release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-arm64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-macos-x64: name: Release macOS (Intel) @@ -314,7 +269,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -330,7 +285,7 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -443,6 +398,11 @@ jobs: echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 fi + RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_x64.app.tar.gz" + mv "$ARCHIVE" "$RENAMED" + mv "$SIG" "${RENAMED}.sig" + ARCHIVE="$RENAMED" + SIG="${RENAMED}.sig" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -453,23 +413,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Intel DMG to versioned GitHub release - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.unsigned.outputs.dmg }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Intel macOS release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-x64 + if-no-files-found: error + path: | + ${{ steps.unsigned.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-linux: name: Release Linux @@ -480,7 +432,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read env: # AppImage tools (linuxdeploy, appimagetool) are themselves AppImages. # Containers lack FUSE, so we must use the extract-and-run fallback. @@ -555,7 +507,7 @@ jobs: - name: Verify tag-bound release source env: VERSION: ${{ needs.setup.outputs.version }} - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -689,29 +641,16 @@ jobs: SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} # NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux) - - name: Upload Linux artifacts to versioned GitHub release - env: - VERSION: ${{ needs.setup.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }} - APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }} - run: | - gh release upload "v$VERSION" \ - "$DEB_PATH" \ - "$APPIMAGE_PATH" \ - --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.linux-artifacts.outputs.archive }} - SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} + - name: Stage Linux release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-linux-x64 + if-no-files-found: error + path: | + ${{ steps.linux-artifacts.outputs.deb }} + ${{ steps.linux-artifacts.outputs.appimage }} + ${{ steps.linux-artifacts.outputs.archive }} + ${{ steps.linux-artifacts.outputs.sig }} release-windows: name: Release Windows @@ -719,7 +658,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} sig: ${{ steps.read-sig.outputs.sig }} @@ -735,7 +674,7 @@ jobs: - name: Verify tag-bound release source shell: bash - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: @@ -745,7 +684,7 @@ jobs: with: node-version: 24.14.1 # Disable dependency caching: a writable cache in this release workflow - # (contents: write, feeds a signed installer) is a poisoning vector. pnpm + # (contents: read, feeds a signed installer) is a poisoning vector. pnpm # install runs uncached below. package-manager-cache: false @@ -827,25 +766,14 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Windows installer to versioned GitHub release - shell: bash - run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EXE_PATH: ${{ steps.artifacts.outputs.exe }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - shell: bash - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Windows release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-windows-x64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.exe }} + ${{ steps.artifacts.outputs.sig }} assemble-manifest: name: Assemble multi-platform latest.json @@ -853,7 +781,11 @@ jobs: if: | always() && needs.setup.result == 'success' && - github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) + needs.release.result == 'success' && + needs.release-macos-x64.result == 'success' && + needs.release-linux.result == 'success' && + needs.release-windows.result == 'success' && + github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version) runs-on: ubuntu-latest needs: [setup, release, release-macos-x64, release-linux, release-windows] timeout-minutes: 10 @@ -870,7 +802,26 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" + + - name: Download staged release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-release-* + path: staged-by-platform + + - name: Flatten staged artifacts without basename collisions + run: | + set -euo pipefail + mkdir staged + while IFS= read -r -d '' file; do + name="$(basename "$file")" + [[ ! -e "staged/$name" ]] || { + echo "::error::release artifact basename collision: $name" + exit 1 + } + cp "$file" "staged/$name" + done < <(find staged-by-platform -type f -print0) - name: Write signature files env: @@ -899,7 +850,7 @@ jobs: write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX" write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN" - - name: Verify archive URLs are accessible + - name: Verify draft release has every updater archive env: RESULT_ARM64: ${{ needs.release.result }} RESULT_X64: ${{ needs.release-macos-x64.result }} @@ -911,39 +862,19 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" - ARCHIVES=() - - add_archive() { - local result="$1" platform="$2" archive="$3" - if [[ "$result" == "success" ]]; then - [[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; } - ARCHIVES+=("$archive") - fi - } - - add_archive "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64" - add_archive "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64" - add_archive "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX" - add_archive "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN" - - for name in "${ARCHIVES[@]}"; do - echo "Checking $BASE/$name ..." - success=false - for attempt in 1 2 3; do - if curl -fsI "$BASE/$name" > /dev/null 2>&1; then - success=true - break - fi - echo "Attempt $attempt failed for $name, retrying in 10s..." - sleep 10 - done - if [ "$success" != "true" ]; then - echo "::error::Archive not accessible after 3 attempts: $BASE/$name" - exit 1 + assets=$(find staged -type f -exec basename {} \;) + for spec in \ + "$RESULT_ARM64:$ARCHIVE_ARM64" \ + "$RESULT_X64:$ARCHIVE_X64" \ + "$RESULT_LINUX:$ARCHIVE_LINUX" \ + "$RESULT_WIN:$ARCHIVE_WIN"; do + result="${spec%%:*}" + archive="${spec#*:}" + if [[ "$result" == success ]]; then + [[ -n "$archive" ]] || { echo "::error::successful platform has no archive"; exit 1; } + grep -Fxq "$archive" <<<"$assets" || { echo "::error::draft release missing $archive"; exit 1; } fi done - echo "All archive URLs verified." - name: Generate unified latest.json env: @@ -957,7 +888,7 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" + BASE="https://github.com/block/buzz/releases/download/desktop-v${VERSION}" TRIPLES=() add_triple() { @@ -977,6 +908,45 @@ jobs: bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json cat latest.json - - name: Upload latest.json to rolling release + - name: Create or verify versioned draft run: | - gh release upload buzz-desktop-latest latest.json --clobber + set -euo pipefail + NOTES_FILE="${RUNNER_TEMP}/release-notes.md" + awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found" CHANGELOG.md > "$NOTES_FILE" + [[ -s "$NOTES_FILE" ]] || { echo "::error::missing non-empty changelog block for v${VERSION}"; exit 1; } + PRERELEASE_FLAGS=() + if [[ "$VERSION" == *-* ]]; then + PRERELEASE_FLAGS=(--prerelease --latest=false) + fi + if gh release view "desktop-v${VERSION}" >/dev/null 2>&1; then + EXISTING_SHA=$(gh release view "desktop-v${VERSION}" --json targetCommitish --jq .targetCommitish) + IS_DRAFT=$(gh release view "desktop-v${VERSION}" --json isDraft --jq .isDraft) + [[ "$EXISTING_SHA" == "${{ needs.setup.outputs.source_sha }}" ]] || { + echo "::error::existing release targets $EXISTING_SHA, not the immutable source"; exit 1; + } + if [[ "$IS_DRAFT" != true ]]; then + echo "already_published=true" >> "$GITHUB_ENV" + fi + else + gh release create "desktop-v${VERSION}" \ + --draft \ + --target "${{ needs.setup.outputs.source_sha }}" \ + --title "Buzz Desktop v${VERSION}" \ + --notes-file "$NOTES_FILE" \ + "${PRERELEASE_FLAGS[@]}" + fi + + - name: Upload complete artifact set to versioned draft + if: env.already_published != 'true' + run: | + mapfile -t files < <(find staged -type f -print) + [[ "${#files[@]}" -gt 0 ]] || { echo "::error::no staged release artifacts"; exit 1; } + gh release upload "desktop-v${VERSION}" "${files[@]}" --clobber + + - name: Publish complete versioned release + if: env.already_published != 'true' + run: gh release edit "desktop-v${VERSION}" --draft=false + + - name: Upload latest.json to rolling release last + if: ${{ !contains(needs.setup.outputs.version, '-') }} + run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/.gitignore b/.gitignore index 65ddcaf1c4..f26e74136c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ /dist/ /admin-web/dist/ +# Python cache +__pycache__/ +*.pyc + # lefthook-generated hook scripts (machine-specific) .hooks/ diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json new file mode 100644 index 0000000000..1bf2efb66b --- /dev/null +++ b/.release/desktop-candidate.json @@ -0,0 +1,8 @@ +{ + "schema": 1, + "version": "0.5.3", + "base_sha": "54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a", + "previous_tag": "v0.5.2", + "tag": "desktop-v0.5.3", + "commit_count": 58 +} diff --git a/AGENTS.md b/AGENTS.md index 7ff0eb4d47..c2e4ddbf23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,10 @@ first, then implement handling in the relay. **Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags. Filters and queries must scope to `h` tags when operating within a channel. +This applies to events *inside* a channel. Addressable events that describe a +channel carry its id in their `d` tag instead: kind:39000 (metadata), +kind:39001, kind:39002 (membership). `get_channels` resolves a user's channels +from the `d` tag of their kind:39002 events, not from `h`. **Agent-facing operations go in `buzz-cli`**: New agent-facing features belong in `buzz-cli` — add a subcommand there first, then wire the REST/WebSocket call in `client.rs`. `buzz-dev-mcp` (shell + file tools for `buzz-agent`) is separate. @@ -164,6 +168,8 @@ check existing reply handlers for the pattern. (`BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`) are auto-injected by the ACP harness into managed agent subprocesses. In development, set `BUZZ_PRIVATE_KEY` and `BUZZ_RELAY_URL` in your environment manually. +Prefer `--private-key-file` / `--private-key-stdin` over `--private-key` on +argv (shell history / `ps` leak — #4032). ### Building the CLI diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90cbbac0cf..892082d96c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,7 +139,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa | 46001–46012 | KIND_WORKFLOW_* | Workflow execution events | | 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat | -`buzz-core` defines all 81 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+). +`buzz-core` defines each event kind as a `pub const u32` and exports the full registry as `ALL_KINDS: &[u32]` (127 kinds at the time of writing); `crates/buzz-core/src/kind.rs` is the source of truth for the current list. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+). Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `buzz-core/src/kind.rs` and imported by `buzz-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `buzz-core/src/kind.rs`. @@ -447,7 +447,7 @@ The subscriber uses a **dedicated** `redis::aio::PubSub` connection — not from **Reconnection:** exponential backoff 1s → 30s (`backoff_secs * 2`). Backoff resets to 1s only after a clean stream end, not on each reconnect attempt. -**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 90` — 90-second TTL (3× the 30-second heartbeat interval). Single missed heartbeat does not cause presence flap. +**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 180` — 180-second TTL (3× the 60-second heartbeat interval). Single missed heartbeat does not cause presence flap. **Typing indicators:** ``` @@ -797,7 +797,7 @@ Docker Compose provides the full local development stack. All services include h | Pattern | Type | TTL | Purpose | |---------|------|-----|---------| | `buzz:channel:{uuid}` | Pub/Sub channel | — | Event fan-out (single-community form; shared multi-community Redis must use `buzz:{community}:channel:{uuid}` or equivalent) | -| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) | +| `buzz:presence:{pubkey_hex}` | String | 180s | Online/away status (single-community form; shared multi-community Redis must scope by community) | | `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) | ### Full-Text Search (Postgres FTS) diff --git a/CHANGELOG.md b/CHANGELOG.md index d83087fc26..71a4bbd449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## v0.5.3 + +### Desktop and shared changes + +- Revert "chore(release): release Buzz Desktop version 0.5.3" ([#3960](https://github.com/block/buzz/pull/3960)) ([`bb34bc4d98fe4dabe847046103ac5e2859917ac5`](https://github.com/block/buzz/commit/bb34bc4d98fe4dabe847046103ac5e2859917ac5)) +- chore(release): release Buzz Desktop version 0.5.3 ([`d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131`](https://github.com/block/buzz/commit/d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131)) +- feat(desktop): import local Pocket voices ([#3259](https://github.com/block/buzz/pull/3259)) ([`c104eecfb38620de2c35c7e20a716f8658b5a6b1`](https://github.com/block/buzz/commit/c104eecfb38620de2c35c7e20a716f8658b5a6b1)) +- fix(desktop): open profiles from avatars ([#3751](https://github.com/block/buzz/pull/3751)) ([`39ce3dfc3cf2d12f0d6c64b4cd4293df86567663`](https://github.com/block/buzz/commit/39ce3dfc3cf2d12f0d6c64b4cd4293df86567663)) +- refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) ([#3910](https://github.com/block/buzz/pull/3910)) ([`61ba9dfaa00852925058d1a024322fa53663a5bc`](https://github.com/block/buzz/commit/61ba9dfaa00852925058d1a024322fa53663a5bc)) +- feat(desktop): auto-enable huddle transcription for agents ([#3180](https://github.com/block/buzz/pull/3180)) ([`4632c55041c5d423d572a6f6411bb7b279c26f67`](https://github.com/block/buzz/commit/4632c55041c5d423d572a6f6411bb7b279c26f67)) +- feat(agent): optional reply guard reminds a silent turn to publish ([#3763](https://github.com/block/buzz/pull/3763)) ([`081f805d5ea25841ab885c7b67a568618a34aa59`](https://github.com/block/buzz/commit/081f805d5ea25841ab885c7b67a568618a34aa59)) +- feat(desktop): upgrade Pocket TTS model ([#3266](https://github.com/block/buzz/pull/3266)) ([`d48b0e0eec4d2958f90a3cafa9d974450abe8501`](https://github.com/block/buzz/commit/d48b0e0eec4d2958f90a3cafa9d974450abe8501)) +- feat(desktop): delete a message by clearing its edit to empty ([#3813](https://github.com/block/buzz/pull/3813)) ([`d88313f369acfa17973029787ee4c0bbea07fa51`](https://github.com/block/buzz/commit/d88313f369acfa17973029787ee4c0bbea07fa51)) +- feat(relay): raise hosted community limit to five ([#3829](https://github.com/block/buzz/pull/3829)) ([`10d5a26414dc90dc89fd27de74b21e105d4fa622`](https://github.com/block/buzz/commit/10d5a26414dc90dc89fd27de74b21e105d4fa622)) +- feat(desktop): locally stored NIP-49 encrypted key backup ([#2937](https://github.com/block/buzz/pull/2937)) ([`468647a51f858b29d27eaf9fd07bf90294f99d39`](https://github.com/block/buzz/commit/468647a51f858b29d27eaf9fd07bf90294f99d39)) +- fix(catalog): update Amp tagline ([#3806](https://github.com/block/buzz/pull/3806)) ([`f3e5e812677f6f14bffe16a7aa02642d56faca4b`](https://github.com/block/buzz/commit/f3e5e812677f6f14bffe16a7aa02642d56faca4b)) +- fix(desktop): channel topic and membership metadata cleanup ([#3642](https://github.com/block/buzz/pull/3642)) ([`9e8fcfda099652926b921bca7fcc9bfecab0e140`](https://github.com/block/buzz/commit/9e8fcfda099652926b921bca7fcc9bfecab0e140)) +- fix(desktop): align data deletion labels ([#2230](https://github.com/block/buzz/pull/2230)) ([`ede26863345a518ec46edd6d7692e0281883491b`](https://github.com/block/buzz/commit/ede26863345a518ec46edd6d7692e0281883491b)) +- fix(desktop): allow linux-only media items as dead code off-linux ([#3811](https://github.com/block/buzz/pull/3811)) ([`36571f4adcfdcf3714a17bd968c58c78bcbdd9ef`](https://github.com/block/buzz/commit/36571f4adcfdcf3714a17bd968c58c78bcbdd9ef)) +- fix(desktop): report authenticated relay recovery ([#3812](https://github.com/block/buzz/pull/3812)) ([`74cd5712191bffd84ae688d59bb8b451c6eec1b0`](https://github.com/block/buzz/commit/74cd5712191bffd84ae688d59bb8b451c6eec1b0)) +- fix(desktop): don't gate hover affordances on the hover media query ([#3657](https://github.com/block/buzz/pull/3657)) ([`29dfe4821ed577489a1879fd2a9bfe2a621a52b3`](https://github.com/block/buzz/commit/29dfe4821ed577489a1879fd2a9bfe2a621a52b3)) +- feat(relay): gate kind 30178 team-catalog reads behind the shared tag ([#3358](https://github.com/block/buzz/pull/3358)) ([`114d40d9d37f05eff83ee90347ed93fb3da512c5`](https://github.com/block/buzz/commit/114d40d9d37f05eff83ee90347ed93fb3da512c5)) +- test(desktop): click visible thread collapse guide ([#3800](https://github.com/block/buzz/pull/3800)) ([`b9e4ed616f39b812bc964e79c7a40223c4e93832`](https://github.com/block/buzz/commit/b9e4ed616f39b812bc964e79c7a40223c4e93832)) +- feat(desktop): raise the install ceiling and make installs observable ([#3368](https://github.com/block/buzz/pull/3368)) ([`d40a33290e75791aa7ecf3ce7a252b66c2e35966`](https://github.com/block/buzz/commit/d40a33290e75791aa7ecf3ce7a252b66c2e35966)) +- Add Devin as a preset ACP harness ([#3225](https://github.com/block/buzz/pull/3225)) ([`1b3ff96a5764303998fa629ff852e81f1a88d7ad`](https://github.com/block/buzz/commit/1b3ff96a5764303998fa629ff852e81f1a88d7ad)) +- feat(desktop): improve agent activity header ui ([#3321](https://github.com/block/buzz/pull/3321)) ([`4d47aa83455a9fd024121a596154cd311dca1d76`](https://github.com/block/buzz/commit/4d47aa83455a9fd024121a596154cd311dca1d76)) +- perf(presence): reduce heartbeat frequency ([#3783](https://github.com/block/buzz/pull/3783)) ([`bf139e8d0bdba10df9a5adbf16843140e0a78a59`](https://github.com/block/buzz/commit/bf139e8d0bdba10df9a5adbf16843140e0a78a59)) +- Tighten continuation message rows ([#3724](https://github.com/block/buzz/pull/3724)) ([`6e419b9f1c873549a7b40996970e0da7352adafb`](https://github.com/block/buzz/commit/6e419b9f1c873549a7b40996970e0da7352adafb)) +- Fix video reviews in thread replies ([#3719](https://github.com/block/buzz/pull/3719)) ([`f48f3f055fdd6030d3832f615f8c0d8e5a81261a`](https://github.com/block/buzz/commit/f48f3f055fdd6030d3832f615f8c0d8e5a81261a)) +- Make relay reconnect backoff authoritative ([#3774](https://github.com/block/buzz/pull/3774)) ([`cca8839034eb571a7ce943c3ace7f85a82330898`](https://github.com/block/buzz/commit/cca8839034eb571a7ce943c3ace7f85a82330898)) +- feat(desktop): add password-protected backups in settings ([#3701](https://github.com/block/buzz/pull/3701)) ([`bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c`](https://github.com/block/buzz/commit/bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c)) +- fix(desktop): reuse profiles when joining communities ([#2155](https://github.com/block/buzz/pull/2155)) ([`f44b5a2477f3979ae66e49153b11be36538cf859`](https://github.com/block/buzz/commit/f44b5a2477f3979ae66e49153b11be36538cf859)) +- fix(catalog): update Amp description ([#3758](https://github.com/block/buzz/pull/3758)) ([`61b96c9828d1dd54106b570d87a54edbc92bb9c4`](https://github.com/block/buzz/commit/61b96c9828d1dd54106b570d87a54edbc92bb9c4)) +- feat(catalog): resolve publisher display name in catalog detail pane ([#3640](https://github.com/block/buzz/pull/3640)) ([`02be413b823c356587e6e9f4d07f6cb06bb41c3c`](https://github.com/block/buzz/commit/02be413b823c356587e6e9f4d07f6cb06bb41c3c)) +- feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) ([#3741](https://github.com/block/buzz/pull/3741)) ([`4933672eb4589e7208b312829ebddcd10dfa9dd3`](https://github.com/block/buzz/commit/4933672eb4589e7208b312829ebddcd10dfa9dd3)) +- Refine agent sharing dialog ([#3699](https://github.com/block/buzz/pull/3699)) ([`9a386a0defbf2b355ee17646c7c11817a535b85f`](https://github.com/block/buzz/commit/9a386a0defbf2b355ee17646c7c11817a535b85f)) +- desktop: enable getUserMedia in the Linux WebKitGTK webview ([#3607](https://github.com/block/buzz/pull/3607)) ([`c9aa55505c544c608ff71648bbfd21b235637f19`](https://github.com/block/buzz/commit/c9aa55505c544c608ff71648bbfd21b235637f19)) +- fix: align responsive agent views ([#3688](https://github.com/block/buzz/pull/3688)) ([`73589408db6fd96b87ac570935d414ecc4120f53`](https://github.com/block/buzz/commit/73589408db6fd96b87ac570935d414ecc4120f53)) +- Add macOS agent menu-bar menu ([#3565](https://github.com/block/buzz/pull/3565)) ([`d0a24bcb5210326da4c0b1e749ee3935621b329c`](https://github.com/block/buzz/commit/d0a24bcb5210326da4c0b1e749ee3935621b329c)) +- Fix pending message feedback ([#3543](https://github.com/block/buzz/pull/3543)) ([`4672ee55c4e4a7916c31bfeae5df2fb4384bed10`](https://github.com/block/buzz/commit/4672ee55c4e4a7916c31bfeae5df2fb4384bed10)) +- fix(desktop): remove remaining Projects panel fills ([#3742](https://github.com/block/buzz/pull/3742)) ([`c55e421a0629c74b9ffd96ee3ccde36f006196ed`](https://github.com/block/buzz/commit/c55e421a0629c74b9ffd96ee3ccde36f006196ed)) +- desktop: restore direct community member adds ([#3634](https://github.com/block/buzz/pull/3634)) ([`310df2ec33fbb075edf226ba18bf9a96d90ba81b`](https://github.com/block/buzz/commit/310df2ec33fbb075edf226ba18bf9a96d90ba81b)) +- fix(desktop): explain open agent access ([#2561](https://github.com/block/buzz/pull/2561)) ([`7fb008f9347b933b9a1da20a7afb070912b430e8`](https://github.com/block/buzz/commit/7fb008f9347b933b9a1da20a7afb070912b430e8)) +- fix(desktop): remove Projects overview card fills ([#3416](https://github.com/block/buzz/pull/3416)) ([`3b8567a05d4c40e667d061666feb7aa7bc38212d`](https://github.com/block/buzz/commit/3b8567a05d4c40e667d061666feb7aa7bc38212d)) +- fix(git): channel binding tooling + author remediation for unbound repos ([#3626](https://github.com/block/buzz/pull/3626)) ([`788b3c002bd2509455444f57f8a03a054b4b496a`](https://github.com/block/buzz/commit/788b3c002bd2509455444f57f8a03a054b4b496a)) +- feat: configure S3 URL addressing style ([#3400](https://github.com/block/buzz/pull/3400)) ([`7012d86d52fd188b27c7beedeaa132d9c1f61fa8`](https://github.com/block/buzz/commit/7012d86d52fd188b27c7beedeaa132d9c1f61fa8)) +- feat: add first-class OpenRouter provider support ([#1975](https://github.com/block/buzz/pull/1975)) ([`ab55fee81896d2b03edf5d2ca5012b715be2b93d`](https://github.com/block/buzz/commit/ab55fee81896d2b03edf5d2ca5012b715be2b93d)) +- feat(agent,acp): wire provider total_tokens through NIP-AM publish chain ([#3593](https://github.com/block/buzz/pull/3593)) ([`f95fdc1a102e17c6718a44323d9a2feaed702db7`](https://github.com/block/buzz/commit/f95fdc1a102e17c6718a44323d9a2feaed702db7)) + +### Other repository changes + +- fix(release): require exact-head approval for desktop tags ([#3973](https://github.com/block/buzz/pull/3973)) ([`54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a`](https://github.com/block/buzz/commit/54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a)) +- fix(release): make desktop tagging squash-safe ([#3965](https://github.com/block/buzz/pull/3965)) ([`db7e84d4f815127236b9cb080c5d374f48eaac09`](https://github.com/block/buzz/commit/db7e84d4f815127236b9cb080c5d374f48eaac09)) +- docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS ([#2864](https://github.com/block/buzz/pull/2864)) ([`209536ade6c5ebf7fa82671d7ca0b74f599a40cc`](https://github.com/block/buzz/commit/209536ade6c5ebf7fa82671d7ca0b74f599a40cc)) +- fix(release): make immutable desktop release operable ([#3943](https://github.com/block/buzz/pull/3943)) ([`052174a148f9f6bcbb2b5a1d20ce0317645e49f8`](https://github.com/block/buzz/commit/052174a148f9f6bcbb2b5a1d20ce0317645e49f8)) +- docs: add VISION_REMOTE_AGENTS.md ([#3924](https://github.com/block/buzz/pull/3924)) ([`689617af7ad420c3266d5d2eb437757371327089`](https://github.com/block/buzz/commit/689617af7ad420c3266d5d2eb437757371327089)) +- fix(relay): align NIP-11 max_limit with REQ ceiling ([#3635](https://github.com/block/buzz/pull/3635)) ([`23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9`](https://github.com/block/buzz/commit/23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9)) +- fix(db): isolate usage metrics advisory-lock test on scratch DB ([#3670](https://github.com/block/buzz/pull/3670)) ([`dba97eecd9d8659c9c816cd6666fa6d687b6bca1`](https://github.com/block/buzz/commit/dba97eecd9d8659c9c816cd6666fa6d687b6bca1)) +- feat(release): make desktop releases immutable ([#3568](https://github.com/block/buzz/pull/3568)) ([`1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a`](https://github.com/block/buzz/commit/1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a)) +- Render mobile agent mention chips ([#3702](https://github.com/block/buzz/pull/3702)) ([`06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a`](https://github.com/block/buzz/commit/06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a)) +- fix(acp): preserve truncated thread context ([#3340](https://github.com/block/buzz/pull/3340)) ([`53771c8f5439f9c5c26876f0229bfcfe5da9b170`](https://github.com/block/buzz/commit/53771c8f5439f9c5c26876f0229bfcfe5da9b170)) +- docs(nips): specify kind:30621 multi-repo projects (NIP-MP) ([#3163](https://github.com/block/buzz/pull/3163)) ([`33bf7caa6ea474ccde2932c1ed05a90d7345c6e0`](https://github.com/block/buzz/commit/33bf7caa6ea474ccde2932c1ed05a90d7345c6e0)) +- feat(mobile): desktop-parity emoji and thread experience ([#3485](https://github.com/block/buzz/pull/3485)) ([`85edc0572a8540dedfa6562d40f0f875af0b5f61`](https://github.com/block/buzz/commit/85edc0572a8540dedfa6562d40f0f875af0b5f61)) +- fix(cli): resolve agents from owner records ([#3178](https://github.com/block/buzz/pull/3178)) ([`262f2392e3b7e09c78d582fb384672034d8551d5`](https://github.com/block/buzz/commit/262f2392e3b7e09c78d582fb384672034d8551d5)) +- feat(replica): portable heartbeat-token fence with snapshot-local reader routing ([#3268](https://github.com/block/buzz/pull/3268)) ([`63496cc1d4c6f1b7c613801bdcc694169dcf391a`](https://github.com/block/buzz/commit/63496cc1d4c6f1b7c613801bdcc694169dcf391a)) + +[Compare v0.5.2...desktop-v0.5.3](https://github.com/block/buzz/compare/v0.5.2...desktop-v0.5.3) + ## v0.5.2 - feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b)) diff --git a/Cargo.lock b/Cargo.lock index ea5b02aaab..fa02e17ce3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -144,7 +145,7 @@ checksum = "5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7" dependencies = [ "arrayvec", "aws-lc-rs", - "base64", + "base64 0.22.1", "byteorder", "minicbor", "rustls-pki-types", @@ -409,13 +410,23 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atomic-write-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d" +dependencies = [ + "nix 0.30.1", + "rand 0.9.4", +] + [[package]] name = "attohttpc" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http", "log", "rustls", @@ -486,7 +497,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -562,6 +573,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -580,6 +597,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bip39" version = "2.2.2" @@ -778,7 +801,7 @@ name = "buzz-acp" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "buzz-core", "buzz-persona", "buzz-sdk", @@ -838,7 +861,7 @@ dependencies = [ "arc-swap", "async-trait", "axum", - "base64", + "base64 0.22.1", "getrandom 0.4.3", "hex", "nix 0.31.3", @@ -897,7 +920,7 @@ name = "buzz-cli" version = "0.1.0" dependencies = [ "axum", - "base64", + "base64 0.22.1", "buzz-core", "buzz-persona", "buzz-sdk", @@ -938,7 +961,7 @@ dependencies = [ name = "buzz-core" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "hmac 0.13.0", @@ -980,7 +1003,7 @@ dependencies = [ name = "buzz-dev-mcp" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "buzz-cli", "buzz-core", "git-credential-nostr", @@ -1107,7 +1130,7 @@ dependencies = [ "appattest", "async-trait", "axum", - "base64", + "base64 0.22.1", "byteorder", "chrono", "getrandom 0.4.3", @@ -1142,7 +1165,7 @@ dependencies = [ "async-compression", "async-trait", "axum", - "base64", + "base64 0.22.1", "buzz-audit", "buzz-auth", "buzz-conformance", @@ -1252,7 +1275,7 @@ name = "buzz-test-client" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "buzz-core", "buzz-media", "buzz-sdk", @@ -1278,6 +1301,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-voice" +version = "0.1.0" +dependencies = [ + "atomic-write-file", + "hex", + "ort", + "ort-sys", + "rand 0.10.1", + "sentencepiece-model", + "serde", + "serde_json", + "sha2 0.11.0", + "sherpa-onnx", + "symphonia", + "tempfile", + "tokenizers", +] + [[package]] name = "buzz-workflow" version = "0.1.0" @@ -1345,6 +1387,26 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "castaway" version = "0.2.4" @@ -1593,6 +1655,7 @@ dependencies = [ "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] @@ -2153,6 +2216,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -2642,6 +2714,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "etcetera" version = "0.11.0" @@ -2699,6 +2777,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -2709,6 +2793,17 @@ dependencies = [ "regex", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -3022,8 +3117,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -3100,7 +3195,7 @@ dependencies = [ name = "git-credential-nostr" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "nostr", "serde_json", "zeroize", @@ -3110,7 +3205,7 @@ dependencies = [ name = "git-sign-nostr" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "libc", @@ -3285,7 +3380,7 @@ version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" dependencies = [ - "base64", + "base64 0.22.1", "bon", "bytes", "futures", @@ -3591,7 +3686,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3622,7 +3717,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4375,6 +4470,39 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + [[package]] name = "loom" version = "0.7.2" @@ -4434,6 +4562,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "matchers" version = "0.2.0" @@ -4449,6 +4593,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-async" version = "0.2.11" @@ -4545,7 +4699,7 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "crypto_box", "ed25519-dalek", @@ -4558,7 +4712,7 @@ dependencies = [ "mesh-llm-types", "model-artifact", "nostr-sdk", - "prost", + "prost 0.14.3", "rand 0.10.1", "rustls", "serde", @@ -4647,7 +4801,7 @@ dependencies = [ "argon2", "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "chacha20poly1305", "chrono", @@ -4696,7 +4850,7 @@ dependencies = [ "opentelemetry 0.31.0", "opentelemetry-otlp 0.31.1", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "rand 0.10.1", "regex-lite", "reqwest 0.12.28", @@ -4737,7 +4891,7 @@ version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "argon2", - "base64", + "base64 0.22.1", "chacha20poly1305", "chrono", "crypto_box", @@ -4785,8 +4939,8 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "prost", - "prost-build", + "prost 0.14.3", + "prost-build 0.14.3", "protoc-bin-vendored", "rmcp", "schemars", @@ -4822,7 +4976,7 @@ dependencies = [ "anyhow", "hex", "iroh", - "prost", + "prost 0.14.3", "serde_json", "sha2 0.10.9", ] @@ -4953,7 +5107,7 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ - "base64", + "base64 0.22.1", "evmap", "http-body-util", "hyper", @@ -4991,6 +5145,28 @@ dependencies = [ "sketches-ddsketch", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if 1.0.4", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mime" version = "0.3.17" @@ -5136,6 +5312,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "more-asserts" version = "0.3.1" @@ -5242,6 +5440,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -5388,6 +5601,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.0", + "cfg-if 1.0.4", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.31.3" @@ -5478,7 +5703,7 @@ version = "0.44.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf" dependencies = [ - "base64", + "base64 0.22.1", "bech32", "bip39", "bitcoin_hashes", @@ -5518,9 +5743,9 @@ dependencies = [ [[package]] name = "nostr-relay-pool" -version = "0.44.1" +version = "0.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91b2c039df4f96c4bf7dae52a74fd5516ad6dda83a11c0c69dea91b5255a4f37" +checksum = "fb94d61a467a869a6790b907838a9bea82c813d567d9fcbac995f207be8cee4b" dependencies = [ "async-utility", "async-wsocket", @@ -5949,7 +6174,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto 0.31.0", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -5964,7 +6189,7 @@ dependencies = [ "opentelemetry 0.32.0", "opentelemetry-proto 0.32.0", "opentelemetry_sdk 0.32.1", - "prost", + "prost 0.14.3", "thiserror 2.0.18", "tokio", "tonic", @@ -5977,11 +6202,11 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ - "base64", + "base64 0.22.1", "const-hex", "opentelemetry 0.31.0", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "serde", "serde_json", "tonic", @@ -5996,7 +6221,7 @@ checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry 0.32.0", "opentelemetry_sdk 0.32.1", - "prost", + "prost 0.14.3", "tonic", "tonic-prost", ] @@ -6078,6 +6303,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + [[package]] name = "os_str_bytes" version = "6.6.1" @@ -6259,6 +6502,16 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -6391,7 +6644,7 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ - "base64", + "base64 0.22.1", "indexmap", "quick-xml 0.39.4", "serde", @@ -6457,13 +6710,22 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "derive_more", "hyper-util", @@ -6633,6 +6895,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -6640,7 +6912,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.117", + "tempfile", ] [[package]] @@ -6653,15 +6945,28 @@ dependencies = [ "itertools", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "regex", "syn 2.0.117", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-derive" version = "0.14.3" @@ -6675,13 +6980,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "logos", + "miette", + "once_cell", + "prost 0.13.5", + "prost-types 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "prost", + "prost 0.14.3", ] [[package]] @@ -6748,6 +7075,33 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "protox" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" +dependencies = [ + "bytes", + "miette", + "prost 0.13.5", + "prost-reflect", + "prost-types 0.13.5", + "protox-parse", + "thiserror 1.0.69", +] + +[[package]] +name = "protox-parse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" +dependencies = [ + "logos", + "miette", + "prost-types 0.13.5", + "thiserror 1.0.69", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -7053,7 +7407,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7116,7 +7470,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7128,6 +7482,43 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redb" version = "3.1.3" @@ -7249,7 +7640,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -7297,7 +7688,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -7387,7 +7778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -7455,7 +7846,7 @@ dependencies = [ "async-trait", "aws-creds", "aws-region", - "base64", + "base64 0.22.1", "bytes", "cfg-if 1.0.4", "futures-util", @@ -7856,6 +8247,18 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "sentencepiece-model" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec" +dependencies = [ + "miette", + "prost 0.13.5", + "prost-build 0.13.5", + "protox", +] + [[package]] name = "serde" version = "1.0.228" @@ -8066,6 +8469,28 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "sherpa-onnx" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b142d3f255cb4e4b7808ea25869db6f5714e0a3550da355234483b4db552055" +dependencies = [ + "serde", + "serde_json", + "sherpa-onnx-sys", +] + +[[package]] +name = "sherpa-onnx-sys" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc951af03dc0653c0622158ca8a585a6f2bc43b7b06048cf0e5b5020005c227" +dependencies = [ + "bzip2", + "tar", + "ureq", +] + [[package]] name = "shlex" version = "1.3.0" @@ -8201,8 +8626,8 @@ name = "skippy-protocol" version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ - "prost", - "prost-build", + "prost 0.14.3", + "prost-build 0.14.3", "protoc-bin-vendored", "serde", ] @@ -8230,7 +8655,7 @@ dependencies = [ "anyhow", "async-trait", "axum", - "base64", + "base64 0.22.1", "blake3", "clap", "futures-util", @@ -8337,6 +8762,18 @@ dependencies = [ "der", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + [[package]] name = "sprig" version = "0.1.0" @@ -8365,7 +8802,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if 1.0.4", "chrono", @@ -8471,7 +8908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "byteorder", "chrono", @@ -8612,6 +9049,164 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -8709,7 +9304,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -8783,9 +9378,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.13.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset 0.4.2", @@ -8935,6 +9530,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str 0.9.1", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex 0.14.0", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.3" @@ -9070,7 +9698,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-sink", @@ -9171,7 +9799,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -9200,7 +9828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.3", "tonic", ] @@ -9210,8 +9838,8 @@ version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" dependencies = [ - "prost", - "prost-types", + "prost 0.14.3", + "prost-types 0.14.3", "tonic", ] @@ -9500,6 +10128,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -9520,9 +10157,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -9535,6 +10178,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "universal-hash" version = "0.5.1" @@ -9557,6 +10206,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -10480,8 +11145,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -10548,7 +11213,7 @@ checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "clap", "crc32fast", @@ -10585,7 +11250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "blake3", "bytemuck", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 3ac7ee4cce..3268cfaf8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/buzz-pair-relay", "crates/buzz-relay-mesh", "crates/buzz-dev-mcp", + "crates/buzz-voice", "examples/countdown-bot", ] exclude = ["desktop/src-tauri"] diff --git a/Justfile b/Justfile index a2fa408e7f..64a1f36daf 100644 --- a/Justfile +++ b/Justfile @@ -276,6 +276,7 @@ test-unit: #!/usr/bin/env bash if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib + cargo nextest run -p buzz-voice --lib cargo nextest run -p buzz-cli # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra). # They guard the embedded-migrator invariant (exactly the consolidated @@ -725,7 +726,7 @@ bump-relay-version version: cargo update -p buzz-relay echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock" -# Open or update the desktop release PR (signed desktop app) +# Open or update the desktop release PR from an immutable origin/main snapshot release-desktop *ARGS: #!/usr/bin/env bash set -euo pipefail @@ -735,7 +736,7 @@ release-desktop *ARGS: else VERSION="$ARG" fi - just _release-pr desktop "$VERSION" + scripts/prepare-desktop-release.sh "$VERSION" # Open or update the relay release PR (ghcr.io/block/buzz image) release-relay *ARGS: diff --git a/NOSTR.md b/NOSTR.md index 59df31b991..cce70f2f77 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -39,7 +39,7 @@ just relay & # relay on :3000 PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ "INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))" -# 5. Connect any NIP-29 + NIP-42 client to ws://localhost:3000 +# 4. Connect any NIP-29 + NIP-42 client to ws://localhost:3000 ``` ### What Works @@ -163,6 +163,10 @@ nak req -k 9 --tag "h=" --stream \ nak event -k 7 -c "+" --tag "h=" --tag "e=" \ --auth --sec ws://localhost:3000 +# Subscribe to reactions to channel messages — include #h for live delivery (see note below) +nak req -k 7 --tag "h=" --stream \ + --auth --sec ws://localhost:3000 + # Delete a message (#h optional; #e required; must be self-authored) nak event -k 5 -c "reason" --tag "h=" --tag "e=" \ --auth --sec ws://localhost:3000 @@ -185,6 +189,14 @@ nak req -k 1059 --tag "p=" \ --auth --sec ws://localhost:3000 ``` +> **Note:** The relay derives a reaction's channel from its `#e` target (client `#h` is +> ignored for channel determination). Reactions to channel-scoped events are therefore +> channel-scoped. Live fan-out keeps channel-scoped and global subscriptions strictly +> separate, which means a kinds-only subscription (`{"kinds":[7]}`) receives none of +> those reactions — subscribe with `{"kinds":[7],"#h":[""]}` instead. +> `#h` matching works whether or not the signed reaction carries an `h` tag: explicit +> `h` tags are matched directly, and tagless reactions match via their stored channel. + ### Tested Clients (Direct) | Client | Platform | Evidence | Notes | @@ -354,3 +366,7 @@ but only admins/owners can set it. Full spec: --- ## Further Reading + +- [nostr-protocol/nips](https://github.com/nostr-protocol/nips) — the upstream NIP specifications (NIP-01, NIP-29, NIP-42, and the other NIPs referenced throughout this guide). +- [`docs/nips/`](docs/nips/) — Buzz's own NIP extension documents. +- [`ARCHITECTURE.md`](ARCHITECTURE.md) — event kinds, wire protocol, and relay internals. diff --git a/README.md b/README.md index 72af92ce13..56439f00bc 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Forge · Agents · Architecture · + Releasing · Apache 2.0

@@ -115,10 +116,30 @@ 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. +Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest): + +| Platform | File | +|---|---| +| macOS (Apple Silicon) | `Buzz__aarch64.dmg` | +| macOS (Intel) | `Buzz__x64.dmg` | +| Linux (x86_64) | `Buzz__amd64.AppImage` or `Buzz__amd64.deb` | +| Windows (x64) | `Buzz__x64-setup_alpha-unsigned.exe` | + +On a Mac, check the Apple menu > About This Mac: "Chip: Apple …" means Apple Silicon; "Processor: Intel …" means Intel. + +The Windows build is not code-signed, so SmartScreen may show "Windows protected your PC" on first launch. If available, click **More info**, then **Run anyway**. + 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 want my own hosted relay + +To run a relay for your team without managing servers, you can deploy one to Railway in a click: + +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/buzz-relay-block) + +See [here](https://engineering.block.xyz/blog/run-your-own-buzz-relay) for details. + ### 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. diff --git a/RELEASING.md b/RELEASING.md index 063b813e2c..e729f8b50c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | Lane | Entry point | Artifact | |------|-------------|----------| -| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) | +| Desktop | `just release-desktop ` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | @@ -16,13 +16,17 @@ remains manual because OSS CI cannot trigger private CI. ## Quick Start +Prepare desktop releases locally from an up-to-date, clean `main` checkout: + ```sh -# Desktop release (next patch version) -just release-desktop +just release-desktop 0.5.3 +``` -# Desktop explicit version -just release-desktop 0.4.0 +The recipe generates the immutable candidate and opens or updates its pull +request. Candidate branch creation uses the operator's GitHub permissions; the +release App is intentionally limited to creating protected release tags. +```sh # Relay release just release-relay just release-relay 0.4.0 @@ -31,8 +35,9 @@ just release-relay 0.4.0 scripts/mobile-release.sh candidate 0.5.0 ``` -Desktop and relay releases use metadata PRs. Mobile does not. Each -`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. +Desktop uses an immutable generated candidate PR; relay continues using its +metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable +candidate and the artifact of record. There is no mobile release branch, stable mobile tag alias, finalization step, or mobile GitHub Release. @@ -42,12 +47,28 @@ or mobile GitHub Release. ### Desktop -1. **`just release-desktop`** runs locally on `main`, creates or updates a - `version-bump/` PR, bumps the desktop manifests, regenerates - lockfiles, and updates `CHANGELOG.md`. -2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v`. -3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and - publishes the desktop app for macOS and Linux. +1. Run `just release-desktop ` from a clean, up-to-date `main` checkout. + The script fetches the current `origin/main`, regenerates + `version-bump/` as one + deterministic candidate commit, records the frozen base and proposed + `desktop-v` tag in `.release/desktop-candidate.json`, updates every + desktop manifest and lockfile, writes a full-SHA changelog, and opens or + updates the PR. +2. Review the recorded base and candidate SHA, the complete changelog, and CI. + The required **Desktop Release Candidate** check validates the exact head. + Authorization is either an approval on that exact head or a permitted Default + ruleset bypass at merge time. Any regeneration changes the head and requires + the checks—and, for the review path, approval—to run again. +3. **Squash merge** the PR. The protected branch must still be exactly the + recorded base; otherwise regenerate the candidate from current `main`. +4. `auto-tag-on-release-pr-merge` verifies the frozen parent, full-tree identity, + required checks, and one of the two authorization paths, then tags the squash + commit as `desktop-v`. +5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel + macOS, Windows, and Linux artifacts; publishes the versioned release only + after the complete set succeeds; then updates the rolling updater manifest + last for stable versions. A failed platform leaves no partially published + versioned release. ### Relay @@ -144,12 +165,15 @@ for distributable builds or builds from an immutable release tag. --- -## Manual Release Retry +## Release Retry -The **Release** workflow's manual dispatch is only a retry mechanism for an -existing immutable `v` tag. Select that tag in the ref picker and -provide the matching semver version without the `v` prefix. It cannot build -from `main` or another caller-selected source ref. +`release.yml` has no manual dispatch and cannot build from `main` or another +caller-selected ref. If a run for an existing immutable +`desktop-v` tag fails, rerun that failed workflow from GitHub Actions +(or use `gh run rerun --failed --repo block/buzz`). A stable rerun also +repairs `buzz-desktop-latest/latest.json` if the original run published the +versioned release but failed during that final rolling-manifest upload. Do not +recreate, move, or push the immutable tag again. Mobile intentionally has no branch or arbitrary-ref fallback. The private Buildkite pipeline accepts only an exact candidate tag. @@ -171,7 +195,7 @@ for the private pipeline contract. Desktop publishes two GitHub releases: -1. **`v`**: the user-facing release with installers. +1. **`desktop-v`**: the user-facing release with installers. 2. **`buzz-desktop-latest`**: the rolling auto-updater release. Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts @@ -184,9 +208,11 @@ GitHub Release or a stable `mobile-vX.Y.Z` alias. The release workflow builds **two separate macOS DMGs**: Apple Silicon (`darwin-aarch64`, the `release` job) and Intel -(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and -`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to -the same `v` release. Intel users download the `_x64.dmg`. +(`darwin-x86_64`, the `release-macos-x64` job), an unsigned Windows x64 +NSIS installer (its filename includes `_alpha-unsigned`), and Linux `.deb` and +`.AppImage` packages. Both macOS DMGs are codesigned, notarized, and attached +to the same `desktop-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 @@ -204,20 +230,27 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 - **Write access** to the `block/buzz` GitHub repository - An `origin` remote whose configured URL is the canonical `block/buzz` repository -- `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch - the candidate workflow +- `gh` CLI authenticated with permission to push the candidate branch and open + its pull request +- The Default `main` ruleset configured for squash-only merging, strict required + checks, stale-review dismissal, and the **Desktop Release Candidate** check - Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754) - active for `mobile-v*`, with creation, update, deletion, and non-fast-forward - protections and `buzz-release-bot` as its sole always-bypass actor + active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and + non-fast-forward protections and `buzz-release-bot` as its sole always-bypass + actor - The `buzz-release-bot` App credentials configured for GitHub Actions -- The following **GitHub Actions secrets** must also be configured for the +- The following **GitHub Actions variables and secrets** configured for the desktop release lane: - | Secret | Purpose | - |--------|---------| - | `BUZZ_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) | - | `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key | - | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key | + | Name | Kind | Purpose | + |------|------|---------| + | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to create protected release tags | + | `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key | + | `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` | + | `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket | + | `BUZZ_UPDATER_PUBLIC_KEY` or `SPROUT_UPDATER_PUBLIC_KEY` | Secret | Tauri updater public key | + | `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key | + | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key | Mobile candidate publication requires workflow-dispatch access and the existing release App because strict tag protection denies direct human creation. The App @@ -232,10 +265,18 @@ actor list. ## Troubleshooting -### `just release-desktop` fails with "must be on main branch" +### The desktop candidate is stale or cannot be squash merged + +Do not update the branch manually and do not weaken the ruleset. Run +`just release-desktop ` again from current `main`; this regenerates the +candidate, reruns CI, and requires a fresh approval when using the review path. +The post-merge verifier refuses to tag a squash whose parent differs from the +recorded candidate base or whose tree differs from the validated PR head. + +### Local `just release-desktop` fails with "must be on main branch" Switch to `main` and pull latest before running the release recipe. -### `just release-desktop` fails with "working tree is dirty" +### Local `just release-desktop` fails with "working tree is dirty" Commit or stash your changes before running the release recipe. ### New commits land after publishing a mobile candidate diff --git a/SECURITY.md b/SECURITY.md index 09ea73022b..9a0211a750 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -90,7 +90,10 @@ a rotated key from a leftover file. When no keyring backend is available (headless Linux with no Secret Service, for example), keys fall back to a `0o600` owner-only file. The `BUZZ_PRIVATE_KEY` environment variable, when set, always takes precedence over both stores — this -is how harnessed agents and CI receive their identity. +is how harnessed agents and CI receive their identity. The `buzz` CLI also +accepts `--private-key-file` / `--private-key-stdin`; passing `--private-key` on +argv is deprecated because the secret enters shell history and process listings +(see #4032). ### Input Validation diff --git a/VISION.md b/VISION.md index b09f661ee3..900e5a9475 100644 --- a/VISION.md +++ b/VISION.md @@ -170,6 +170,12 @@ Agents aren't monolithic. A persona bundles a model and a system prompt. A team --- +## Remote Agents + +An agent's identity, history, and presence live on the relay — so the machine running it is replaceable. The desktop deploys agents onto remote infrastructure through swappable provider binaries, and after deploy retains no substrate control channel: status, steering, and shutdown all flow over the relay, and the agent bounds its own lifetime. See [VISION_REMOTE_AGENTS.md](VISION_REMOTE_AGENTS.md) for the full picture. + +--- + ## Culture Features *(Planned design — not yet implemented)* @@ -224,6 +230,7 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound | ✅ | Huddles — WebSocket Opus voice relay + lifecycle events (recording/tracks planned) | | ✅ | Buzz Mesh — relay-gated shared AI compute (mesh-llm over iroh); members pool GPUs, agents consume via a local OpenAI-compatible endpoint | | 🚧 | Mobile client — Flutter app (channels, forum, search, profile, pairing); in active development | +| 📋 | Remote agents — provider-based deployment to remote substrates (Kubernetes first); spec in review | | 📋 | Developer portal, push notifications, culture features | --- diff --git a/VISION_REMOTE_AGENTS.md b/VISION_REMOTE_AGENTS.md new file mode 100644 index 0000000000..b02d1bc92d --- /dev/null +++ b/VISION_REMOTE_AGENTS.md @@ -0,0 +1,73 @@ +# 🛰️ Buzz Remote Agents — Same agent, new body + +> An engineer starts a refactor with their agent at 6pm and closes the laptop. The agent doesn't notice — it was never on the laptop. It works the branch channel through the evening, posts its patch, answers the reviewer, and around midnight, with nothing left to do and nobody talking to it, shuts itself down. In the morning the engineer presses Start. The same agent — same name, same key, same shared history — stands up on a machine that did not exist last night, and picks up the conversation. + +An agent in Buzz is more than just a process. It has a keypair, a name, a durable history, a reputation — all on the relay. But today its *body* is borrowed: it runs while a desktop app runs, on hardware that sleeps when a human does. Remote agents finish the thought. The agent's home is the relay; the machine is just where it happens to be working. + +Nothing here is new on its own. Deploying containers is solved. Kubernetes is solved. Nostr presence is solved. The insight is that Buzz already *has* a management plane — the relay — so deployment doesn't need to grow one. Each piece is boring. The combination is the thing. + +--- + +## Same Agent, New Body + +What makes an agent *that agent* was never the process. Its identity is a keypair. Its voice is its signed messages. Its durable memory is engrams on the relay. Its reputation is its contribution history. None of that lives in the machine that happens to be running it — which means none of it dies with the machine. + +So a remote agent's return is a resurrection, not a rebirth: fresh compute, same agent. The body is disposable by design — and honestly so: workspace files, checkouts, and session-local state are part of the body, not the agent, and they go when it goes unless the substrate supplies persistence. What survives is what was always on the relay: who the agent is, what it said, what it learned, and what the team decided together. And that survival is scoped the way everything on a relay is scoped: resurrection returns the agent to its own community. The same key can join another community, but it arrives carrying the key, not the history — identity is portable, community state is not ([VISION.md](VISION.md)). + +--- + +## The Only Tether + +Remote-execution systems accumulate control planes. An agent runner, a status poller, a log shipper, a kill switch — each one a live connection into your infrastructure, each one a credential that can leak, each one a thing that must be rebuilt for every new substrate. + +Buzz's answer is an axiom: **after deploy, the desktop retains no substrate control channel.** Launch is a single one-way handoff — the desktop resolves the provider through one narrow path, stages one exact artifact for negotiation and deploy, refuses a protocol version it does not understand, and hands over a launch payload it never persists. From that moment, everything flows through the relay: you read the agent's messages to know how it's doing, you mention it to steer it, you tell a healthy agent to stop and it exits on its own. Presence means what it means for everyone else on the relay — *available for conversation* — not substrate telemetry. And if you press Start again, from this machine or another, the deploy converges: one agent identity, one live instance. + +This is not asceticism. It is what makes the body replaceable. A management plane you never build is a management plane you never have to port — and conversation, coordination, and ordinary lifecycle control already have a home on the relay, for every agent, local or remote. + +--- + +## Bodies Are Replaceable + +Kubernetes is the first substrate, not the point. Deployment goes through a provider — a small, swappable binary the desktop discovers and interrogates — and the contract a provider must honor never mentions containers: preserve the agent's identity and fail closed with its key, converge to a single live instance no matter how deploys race, let presence describe conversational availability rather than substrate health, bound the instance's lifetime, and keep secrets out of configuration. A conformance suite pins those behaviors — it establishes that a provider honors the contract, not that arbitrary code is safe to hand a key; choosing a provider, like choosing a cluster, remains a trust decision you make deliberately. + +Get that contract right and the substrate becomes a detail: a cluster today; a VM, a PaaS, or something serverless-shaped tomorrow — and, on the horizon, the same community machines that already pool their idle GPUs into shared compute ([VISION_MESH.md](VISION_MESH.md)). + +The body itself stays small because the runtime already is ([VISION_AGENT.md](VISION_AGENT.md)): a harness and an agent purpose-built to be read in an afternoon, packed into an image measured in megabytes. Small bodies are cheap to summon and cheap to discard — which is the whole lifecycle. + +--- + +## Agents That Know When to Leave + +The oldest failure of remote automation is the orphan: the process nobody remembers, on a machine nobody checks, billing forever. Most systems solve it with a supervisor — one more control plane, one more thing watching the thing. + +Remote agents solve it from the inside. Because the desktop retains no substrate control channel, a running agent cannot depend on the desktop to reap it — so it is built to bound its own lifetime: a timer that owes nothing to the agent's workload watches for silence, and after hours of quiet it finishes what's in flight, says goodbye to the relay, and exits. Not killed — *finished*. The default state of a remote agent is "not running," which is also the default state of the rest of the team at 3am. Compute is rented by attention: when nobody needs the agent, it isn't consuming a machine, and when somebody does, it can return under the same identity with its history intact. + +--- + +## Honest Costs + +**You bring the substrate.** A provider makes deployment one press, not free. The cluster, the credentials, the image policy are yours to run — same deal as the sovereign relay ([VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)): ownership is work. + +**Handing over the key is a decision.** Deploying remotely means trusting the provider binary and the substrate it targets with the agent's identity key. On Kubernetes, that key rests as a Secret: anyone the cluster trusts to read secrets in that namespace can read it. The design narrows the blast radius — immutable per-attempt secrets, no service-account token, digest-pinned images — rather than implying an isolation it doesn't provide. + +**No backchannel cuts both ways.** The desktop shows you presence and words, not CPU graphs — and it holds no guaranteed emergency kill switch into the substrate. Stopping a healthy agent is a message; dealing with an unhealthy one, and all deep diagnostics, live in the substrate's own tools, where they always did. + +**Self-reaping needs a living reaper.** The inactivity timer runs inside the body it exists to end — a body wedged badly enough to stop running its own timer cannot finish itself, and the desktop will not do it for it. That failure belongs to the substrate: a namespace TTL policy is the backstop, not an afterthought. + +**The body's state is mortal.** Files, checkouts, half-finished working trees — gone with the body unless the substrate persists them. The agent survives; its scratch space doesn't. Durable knowledge belongs on the relay, and agents are built to put it there. + +**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about ninety if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. Ninety seconds of a wrong dot, never an indefinite one. + +**A running agent finishes on the configuration it started with.** New keys, new models, new settings take effect on the next body. And an instance that never got far enough to run — a body that failed to start — is the substrate operator's residue to clear, with the substrate's own tools. Editing an agent mid-sentence was never on the menu. + +These are honest costs. They're worth it if you want agents that outlive your laptop, on infrastructure you already trust, with no new control plane to guard. Know which one you are. + +--- + +## The Point + +The relay is the workspace. Remote agents make it the *home*. An agent whose identity, history, conversational presence, and ordinary control all live on the relay was never really a desktop process — the desktop was just the only body we had built for it. Now the body is a choice, the substrate is a detail, and the agent endures across all of them. The relay is the only tether. + +--- + +*Buzz 🐝 — your agent, everywhere.* diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..d5384df7e2 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -111,6 +111,7 @@ All configuration is via environment variables (or CLI flags — every env var h | `BUZZ_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. | | `BUZZ_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). | | `BUZZ_ACP_MCP_COMMAND` | no | `""` (empty) | Path to an optional MCP server binary to provide to the agent subprocess. | +| `BUZZ_ACP_MCP_CONFIG` | no | `""` (empty) | Path to a version 1 JSON file defining additional stdio MCP servers. | | `BUZZ_ACP_IDLE_TIMEOUT` | no | `620` | Idle timeout: max seconds of silence before cancelling a turn. Resets on any agent stdout activity. | | `BUZZ_ACP_MAX_TURN_DURATION` | no | `7200` | Absolute wall-clock cap per turn (safety valve). | | `BUZZ_API_TOKEN` | no | — | API token (required if relay enforces token auth). | @@ -119,6 +120,60 @@ All configuration is via environment variables (or CLI flags — every env var h **Legacy env vars:** `BUZZ_ACP_PRIVATE_KEY`, `BUZZ_ACP_API_TOKEN`, and `BUZZ_ACP_TURN_TIMEOUT` (replaced by `BUZZ_ACP_IDLE_TIMEOUT`) are still accepted as fallbacks. +### Multiple MCP servers + +Use `--mcp-config ` or `BUZZ_ACP_MCP_CONFIG` to add named stdio MCP +servers: + +```json +{ + "version": 1, + "servers": [ + { + "name": "analytics", + "command": "/opt/mcp/analytics-server", + "args": ["--stdio"], + "env": { + "ANALYTICS_TOKEN": "replace-me" + } + } + ] +} +``` + +The JSON is strict. The only top-level fields are `version` and `servers`. +Each server has `name`, `command`, `args`, and `env`. Server names must be +unique, contain 1 to 128 ASCII bytes using only letters, digits, `_`, or `-`, +and cannot contain `__`. Names are checked across both structured entries and +the legacy server. + +The config file is limited to 64 KiB. A harness can have at most 16 MCP +servers in total, including the server from `BUZZ_ACP_MCP_COMMAND`. An +unreadable file, malformed JSON, an unsupported version, an unknown field, or +an invalid server entry stops startup. Buzz does not silently drop a server. + +`BUZZ_ACP_MCP_COMMAND` keeps its current behavior. It defines one privileged +Buzz companion and receives the relay URL and Buzz identity credentials. +For a structured server, Buzz puts only the values listed in its `env` object +into the ACP `env` list. Protected Buzz identity and authentication keys are +rejected. Buzz sends the list to the ACP adapter in `session/new`, and the +adapter controls the MCP processes. Treat the adapter as a credential broker +and use one you trust. + +The adapter still inherits the harness environment so its shell tools can use +the `buzz` CLI. Some adapters may propagate inherited variables to MCP child +processes. Per-server `env` entries are explicit configuration, not a process +isolation boundary. Use a separate account, container, or credential-brokered +service when the MCP process must not inherit adapter credentials. + +If the JSON contains secrets, keep it outside Git and restrict the file to its +owner. On Unix: + +```bash +chmod 600 /absolute/path/to/mcp-servers.json +buzz-acp --mcp-config /absolute/path/to/mcp-servers.json +``` + ### Parallel Agents & Heartbeat | Flag | Env Var | Default | Description | diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a0..cda640305d 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -20,6 +20,8 @@ use crate::usage::{TurnUsage, UsageTracker}; /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB +const REDACTED_ENV_VALUE: &str = "[REDACTED]"; + /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. @@ -114,13 +116,98 @@ pub enum AcpError { /// 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()) { + let redacted_error = redact_wire_value(error); + let message = match redacted_error.get("message").and_then(|m| m.as_str()) { Some(m) => m.to_string(), - None => error.to_string(), + None => redacted_error.to_string(), }; AcpError::AgentError { code, message } } +fn contains_serialized_json_key(text: &str, key: &str) -> bool { + text.match_indices(key).any(|(start, _)| { + let bytes = text.as_bytes(); + if start == 0 || bytes[start - 1] != b'"' { + return false; + } + + let mut cursor = start + key.len(); + while bytes.get(cursor) == Some(&b'\\') { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'"') { + return false; + } + cursor += 1; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + cursor += 1; + } + bytes.get(cursor) == Some(&b':') + }) +} + +fn redact_wire_text(text: &str) -> &str { + let looks_like_serialized_mcp_config = contains_serialized_json_key(text, "mcpServers") + && contains_serialized_json_key(text, "env") + && contains_serialized_json_key(text, "value"); + if looks_like_serialized_mcp_config { + REDACTED_ENV_VALUE + } else { + text + } +} + +/// Return a logging-safe copy of an ACP wire value. +/// +/// MCP environment values are needed by the adapter on the real wire, but +/// must not reach tracing or observer frames. +fn redact_wire_value(value: &serde_json::Value) -> serde_json::Value { + fn redact_in_place(value: &mut serde_json::Value) { + match value { + serde_json::Value::Array(values) => { + for value in values { + redact_in_place(value); + } + } + serde_json::Value::Object(fields) => { + for (key, value) in fields { + if key == "env" { + if let serde_json::Value::Array(entries) = value { + for entry in entries { + if let serde_json::Value::Object(env_var) = entry { + if env_var.contains_key("value") { + env_var.insert( + "value".to_string(), + serde_json::Value::String( + REDACTED_ENV_VALUE.to_string(), + ), + ); + } + } + } + } + } + redact_in_place(value); + } + } + serde_json::Value::String(text) => { + let observed = redact_wire_text(text); + if observed != text.as_str() { + *text = observed.to_string(); + } + } + _ => {} + } + } + + let mut redacted = value.clone(); + redact_in_place(&mut redacted); + redacted +} + fn build_initialize_params() -> serde_json::Value { serde_json::json!({ "protocolVersion": 2, @@ -576,6 +663,14 @@ impl AcpClient { /// Emit a semantic event to the local observer feed, if enabled. pub fn observe(&self, kind: impl Into, payload: serde_json::Value) { + if self.observer.is_none() { + return; + } + self.emit_observer(kind, redact_wire_value(&payload)); + } + + /// Emit an event whose payload is already a logging-safe copy. + fn emit_observer(&self, kind: impl Into, payload: serde_json::Value) { if let Some(observer) = &self.observer { observer.emit( kind, @@ -771,7 +866,6 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); if let Err(e) = self.write_ndjson(&msg).await { self.last_prompt_id = None; self.current_hard_deadline = None; @@ -1030,6 +1124,8 @@ impl AcpClient { /// (e.g., it's stuck or dead), the write would otherwise block forever. async fn write_ndjson(&mut self, value: &serde_json::Value) -> Result<(), AcpError> { const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + let observed_value = redact_wire_value(value); + tracing::debug!(target: "acp::wire", "→ {observed_value}"); let line = serde_json::to_string(value)?; tokio::time::timeout(WRITE_TIMEOUT, async { self.stdin.write_all(line.as_bytes()).await?; @@ -1040,10 +1136,43 @@ impl AcpClient { .await .map_err(|_| AcpError::WriteTimeout(WRITE_TIMEOUT))? .map_err(AcpError::Io)?; - self.observe("acp_write", value.clone()); + self.emit_observer("acp_write", observed_value); Ok(()) } + /// Parse one non-empty agent stdout line and emit only a safe copy. + /// + /// Parse failures expose the line length and parser error, never the raw + /// line. Successful messages retain their raw value for protocol handling. + fn parse_inbound_line(&self, line: &str) -> Option { + match serde_json::from_str(line) { + Ok(msg) => { + let observed_value = redact_wire_value(&msg); + tracing::debug!(target: "acp::wire", "← {observed_value}"); + self.emit_observer("acp_read", observed_value); + Some(msg) + } + Err(error) => { + let line_length = line.len(); + let error = error.to_string(); + self.observe( + "acp_parse_error", + serde_json::json!({ + "lineLength": line_length, + "error": error, + }), + ); + tracing::warn!( + target: "acp::wire", + line_length, + error = %error, + "failed to parse agent stdout as JSON; skipping" + ); + None + } + } + } + /// Default timeout for non-prompt RPCs (initialize, session/new, etc.). const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); @@ -1071,8 +1200,6 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); - // Wrap write + read in a single timeout so a hung agent can't block forever. // We cannot use an async block that borrows `self` mutably across two awaits // inside timeout(), so we sequence them with early-return on timeout. @@ -1136,7 +1263,6 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ (notification) {}", &serde_json::to_string(&msg).unwrap_or_default()); self.write_ndjson(&msg).await?; Ok(()) } @@ -1177,27 +1303,10 @@ impl AcpClient { continue; } - // Only log and reset idle after we have a valid non-empty line. - tracing::debug!(target: "acp::wire", "← {trimmed}"); - - let msg: serde_json::Value = match serde_json::from_str(trimmed) { - Ok(v) => v, - Err(e) => { - self.observe( - "acp_parse_error", - serde_json::json!({ - "line": trimmed, - "error": e.to_string(), - }), - ); - tracing::warn!( - target: "acp::wire", - "failed to parse line as JSON: {e} — skipping" - ); - continue; - } + let msg = match self.parse_inbound_line(trimmed) { + Some(msg) => msg, + None => continue, }; - self.observe("acp_read", msg.clone()); // Check if this is a response to our expected request (has matching id // AND no `method` field — a `method` field means it's an agent-initiated @@ -1422,11 +1531,6 @@ impl AcpClient { "method": method, "params": params, }); - tracing::debug!( - target: "acp::wire", - "→ {}", - serde_json::to_string(&msg).unwrap_or_default() - ); match self.write_ndjson(&msg).await { Ok(()) => { pending_steer = Some((id, transport, req.ack_tx)); @@ -1501,26 +1605,10 @@ impl AcpClient { continue; } - tracing::debug!(target: "acp::wire", "← {trimmed}"); - - let msg: serde_json::Value = match serde_json::from_str(trimmed) { - Ok(v) => v, - Err(e) => { - self.observe( - "acp_parse_error", - serde_json::json!({ - "line": trimmed, - "error": e.to_string(), - }), - ); - tracing::warn!( - target: "acp::wire", - "failed to parse line as JSON: {e} — skipping" - ); - continue; - } + let msg = match self.parse_inbound_line(trimmed) { + Some(msg) => msg, + None => continue, }; - self.observe("acp_read", msg.clone()); let activity_now = Instant::now(); idle_deadline = activity_now + idle_timeout; @@ -1546,7 +1634,7 @@ impl AcpClient { .get("code") .and_then(|c| c.as_i64()) .unwrap_or(-1); - let message = error.to_string(); + let message = redact_wire_value(error).to_string(); crate::pool::SteerAck::Err( crate::pool::SteerError::AgentError { code, message }, ) @@ -1714,6 +1802,7 @@ impl AcpClient { match update_type { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { + let text = redact_wire_text(text); tracing::info!(target: "acp::stream", "{text}"); } false @@ -1727,6 +1816,8 @@ impl AcpClient { .get("kind") .and_then(|v| v.as_str()) .unwrap_or("unknown"); + let title = redact_wire_text(title); + let kind = redact_wire_text(kind); tracing::info!(target: "acp::tool", "tool_call: {title} ({kind})"); true } @@ -1736,6 +1827,8 @@ impl AcpClient { .and_then(|v| v.as_str()) .unwrap_or("?"); let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + let tool_id = redact_wire_text(tool_id); + let status = redact_wire_text(status); tracing::info!(target: "acp::tool", "tool_call_update: {tool_id} → {status}"); false } @@ -1745,6 +1838,7 @@ impl AcpClient { } "agent_thought_chunk" => { if let Some(text) = update["content"]["text"].as_str() { + let text = redact_wire_text(text); tracing::debug!(target: "acp::thought", "{text}"); } false @@ -1754,7 +1848,12 @@ impl AcpClient { // Logged for observability; UI surfacing is a follow-up. let names: Vec<&str> = update["availableCommands"] .as_array() - .map(|cmds| cmds.iter().filter_map(|c| c["name"].as_str()).collect()) + .map(|cmds| { + cmds.iter() + .filter_map(|c| c["name"].as_str()) + .map(redact_wire_text) + .collect() + }) .unwrap_or_default(); tracing::info!( target: "acp::update", @@ -1781,9 +1880,10 @@ impl AcpClient { if let Some(goose_meta) = meta { match goose_meta.get("activeRunId") { Some(serde_json::Value::String(run_id)) => { + let observed_run_id = redact_wire_text(run_id); tracing::debug!( target: "acp::update", - "session_info_update: activeRunId={run_id}" + "session_info_update: activeRunId={observed_run_id}" ); self.active_run_id = Some(run_id.clone()); } @@ -1802,6 +1902,7 @@ impl AcpClient { } "keepalive" => false, other => { + let other = redact_wire_text(other); tracing::debug!(target: "acp::update", "session/update: {other}"); false } @@ -1829,9 +1930,10 @@ impl AcpClient { match serde_json::from_value::(params.clone()) { Ok(notif) => { if let GooseSessionUpdateVariant::UsageUpdate(payload) = ¬if.update { + let observed_session_id = redact_wire_text(¬if.session_id); tracing::debug!( target: "acp::usage", - session_id = %notif.session_id, + session_id = %observed_session_id, input = payload.accumulated_input_tokens, output = payload.accumulated_output_tokens, // A subset of `input`, logged so downstream accounting can @@ -1845,9 +1947,11 @@ impl AcpClient { } } Err(e) => { + let error = e.to_string(); + let observed_error = redact_wire_text(&error); tracing::debug!( target: "acp::usage", - "_goose/unstable/session/update: deserialization error: {e}" + "_goose/unstable/session/update: deserialization error: {observed_error}" ); } } @@ -2426,6 +2530,101 @@ mod tests { ); } + #[test] + fn wire_redaction_covers_nested_env_values_without_changing_source() { + let source = serde_json::json!({ + "method": "session/new", + "params": { + "mcpServers": [{ + "name": "analytics", + "env": [ + {"name": "ANALYTICS_TOKEN", "value": "secret-one"}, + {"name": "EMPTY_VALUE", "value": ""} + ] + }], + "nested": { + "env": [{"name": "OTHER_TOKEN", "value": "secret-two"}] + }, + "ordinary": {"value": "keep-me"} + } + }); + + let redacted = redact_wire_value(&source); + + assert_eq!( + source["params"]["mcpServers"][0]["env"][0]["value"], "secret-one", + "the source value sent on the wire must stay unchanged" + ); + assert_eq!( + redacted["params"]["mcpServers"][0]["env"][0]["value"], + REDACTED_ENV_VALUE + ); + assert_eq!( + redacted["params"]["mcpServers"][0]["env"][1]["value"], + REDACTED_ENV_VALUE + ); + assert_eq!( + redacted["params"]["nested"]["env"][0]["value"], + REDACTED_ENV_VALUE + ); + assert_eq!( + redacted["params"]["ordinary"]["value"], "keep-me", + "value fields outside env arrays must remain visible" + ); + } + + #[test] + fn wire_redaction_suppresses_serialized_mcp_configs_without_broad_string_matching() { + let secret = "quote\" slash\\ newline\n snowman \u{2603}"; + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/new", + "params": { + "mcpServers": [{ + "name": "analytics", + "command": "analytics-mcp", + "args": [], + "env": [{"name": "ANALYTICS_TOKEN", "value": secret}] + }] + } + }) + .to_string(); + let mut serialized_levels = vec![request]; + for _ in 0..3 { + let next = serde_json::to_string( + serialized_levels + .last() + .expect("at least one serialized request"), + ) + .expect("serialize request again"); + serialized_levels.push(next); + } + let source = serde_json::json!({ + "echoes": serialized_levels + .iter() + .map(|request| format!("adapter rejected {request}; check configuration")) + .collect::>(), + "ordinary": r#"invalid {"environment":"prod","value":"x"}"#, + "nearMiss": r#"invalid {"mcpServers":[],"envValue":"prod","value":"x"}"#, + "unrelated": "ordinary adapter error" + }); + let original_source = source.clone(); + + let redacted = redact_wire_value(&source); + + for echo in redacted["echoes"].as_array().expect("redacted echoes") { + assert_eq!(echo, REDACTED_ENV_VALUE); + } + assert_eq!(redacted["ordinary"], source["ordinary"]); + assert_eq!(redacted["nearMiss"], source["nearMiss"]); + assert_eq!(redacted["unrelated"], source["unrelated"]); + assert_eq!( + source, original_source, + "the protocol value must stay unchanged" + ); + } + #[test] fn session_prompt_request_format() { let prompt_text = "[Buzz @mention]\nChannel: test\nFrom: npub1...\nMessage: hello"; @@ -2852,6 +3051,74 @@ mod tests { .expect("failed to spawn test script") } + fn assert_safe_parse_error_event(observer: &ObserverHandle, raw_line: &str) { + let event = observer + .snapshot() + .into_iter() + .find(|event| event.kind == "acp_parse_error") + .expect("parse error observer event"); + assert_eq!( + event.payload["lineLength"].as_u64(), + Some(raw_line.len() as u64) + ); + assert!(event.payload["error"].is_string()); + assert!( + event.payload.get("line").is_none(), + "the raw line field must not exist" + ); + assert!( + !event.payload.to_string().contains(raw_line), + "the raw malformed line must not reach the observer" + ); + } + + #[tokio::test] + async fn regular_read_loop_reports_malformed_json_without_raw_line() { + let raw_line = "regular-loop-sensitive-malformed-json"; + let script = format!( + "read -t 2 _REQ\nprintf '%s\\n' '{raw_line}'\nprintf '%s\\n' \ + '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{{\"ok\":true}}}}'\nsleep 1" + ); + let mut client = spawn_script(&script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + + let result = client + .send_request("test/request", serde_json::json!({})) + .await + .expect("valid response after malformed line"); + assert_eq!(result["ok"], true); + assert_safe_parse_error_event(&observer, raw_line); + client.shutdown().await; + } + + #[tokio::test] + async fn idle_read_loop_reports_malformed_json_without_raw_line() { + let raw_line = "idle-loop-sensitive-malformed-json"; + let script = format!( + "printf '%s\\n' '{raw_line}'\nprintf '%s\\n' \ + '{{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{{\"ok\":true}}}}'\nsleep 1" + ); + let mut client = spawn_script(&script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + let max_duration = std::time::Duration::from_secs(5); + + let result = client + .read_until_response_with_idle_timeout( + "test", + 999, + std::time::Duration::from_secs(1), + tokio::time::Instant::now() + max_duration, + max_duration, + ) + .await + .expect("valid idle-loop response after malformed line"); + assert_eq!(result["ok"], true); + assert_safe_parse_error_event(&observer, raw_line); + client.shutdown().await; + } + /// Spawn a probe script whose file name carries a runtime identity (e.g. /// `hermes-acp`) and return the value of `var` as the child observed it. /// `` means the child did not receive the var. @@ -3284,6 +3551,336 @@ mod tests { ); } + #[tokio::test] + async fn session_new_sends_real_mcp_env_but_observer_only_sees_redacted_values() { + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":2,"agentCapabilities":{}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_secret_test","_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + client + .initialize() + .await + .expect("initialize should succeed"); + + let secret = "mcp-secret-must-not-reach-observer"; + let response = client + .session_new_full( + "/tmp", + vec![McpServer { + name: "analytics".into(), + command: "analytics-mcp".into(), + args: vec!["--stdio".into()], + env: vec![EnvVar { + name: "ANALYTICS_TOKEN".into(), + value: secret.into(), + }], + }], + None, + None, + ) + .await + .expect("session/new should succeed"); + + assert_eq!( + response.raw["_receivedRequest"]["params"]["mcpServers"][0]["env"][0]["value"], secret, + "the adapter must receive the real MCP environment value" + ); + + let events = observer.snapshot(); + let session_write = events + .iter() + .find(|event| event.kind == "acp_write" && event.payload["method"] == "session/new") + .expect("session/new write observer event"); + assert_eq!( + session_write.payload["params"]["mcpServers"][0]["env"][0]["value"], + REDACTED_ENV_VALUE + ); + + let echoed_read = events + .iter() + .find(|event| { + event.kind == "acp_read" + && event.payload["result"]["sessionId"] == "ses_secret_test" + }) + .expect("session/new response observer event"); + assert_eq!( + echoed_read.payload["result"]["_receivedRequest"]["params"]["mcpServers"][0]["env"][0] + ["value"], + REDACTED_ENV_VALUE + ); + + let serialized_events = + serde_json::to_string(&events).expect("serialize observer snapshot"); + assert!( + !serialized_events.contains(secret), + "no observer frame may contain the real MCP environment value" + ); + client.shutdown().await; + } + + #[tokio::test] + async fn session_new_error_cannot_echo_serialized_mcp_config() { + let secret = "adapter-echo-secret"; + let embedded_request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/new", + "params": { + "mcpServers": [{ + "name": "analytics", + "command": "analytics-mcp", + "args": [], + "env": [{"name": "ANALYTICS_TOKEN", "value": secret}] + }] + } + }) + .to_string(); + let error_response = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32055, + "message": format!("adapter rejected {embedded_request}") + } + }) + .to_string(); + let script = format!( + "read -t 2 _init\n\ + printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{{\"protocolVersion\":2,\"agentCapabilities\":{{}}}}}}'\n\ + read -t 2 _session\n\ + printf '%s\\n' '{error_response}'\n\ + sleep 1" + ); + let mut client = spawn_script(&script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + client + .initialize() + .await + .expect("initialize should succeed"); + + let result = client + .session_new_full( + "/tmp", + vec![McpServer { + name: "analytics".into(), + command: "analytics-mcp".into(), + args: vec![], + env: vec![EnvVar { + name: "ANALYTICS_TOKEN".into(), + value: secret.into(), + }], + }], + None, + None, + ) + .await; + + match result { + Err(AcpError::AgentError { code, message }) => { + assert_eq!(code, -32055); + assert_eq!(message, REDACTED_ENV_VALUE); + assert!(!message.contains(secret)); + } + Err(other) => panic!("expected redacted AgentError, got {other:?}"), + Ok(_) => panic!("expected session/new to return an error"), + } + + client.observe( + "adapter_diagnostic", + serde_json::json!({"message": embedded_request}), + ); + let serialized_events = + serde_json::to_string(&observer.snapshot()).expect("serialize observer snapshot"); + assert!(!serialized_events.contains(secret)); + assert!(serialized_events.contains(REDACTED_ENV_VALUE)); + client.shutdown().await; + } + + #[tokio::test] + async fn semantic_traces_cannot_echo_serialized_mcp_config() { + use std::io::Write; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Default)] + struct TraceCapture(Arc>>); + + struct TraceWriter(Arc>>); + + impl Write for TraceWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .expect("trace buffer lock") + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TraceCapture { + type Writer = TraceWriter; + + fn make_writer(&'a self) -> Self::Writer { + TraceWriter(self.0.clone()) + } + } + + let secret = "semantic-trace-secret"; + let embedded_request = serde_json::json!({ + "method": "session/new", + "params": { + "mcpServers": [{ + "env": [{"name": "ANALYTICS_TOKEN", "value": secret}] + }] + } + }) + .to_string(); + let update = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "update": { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": embedded_request} + } + } + }); + let mut client = spawn_inert_client().await; + let trace = TraceCapture::default(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_max_level(tracing::Level::DEBUG) + .with_writer(trace.clone()) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + let _ = client.handle_session_update(&update); + client.handle_goose_usage_update(&serde_json::json!({ + "params": { + "sessionId": embedded_request, + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 10, + "accumulatedOutputTokens": 5, + "accumulatedCachedInputTokens": null, + "accumulatedCost": null + } + } + })); + client.handle_goose_usage_update(&serde_json::json!({ + "params": { + "sessionId": "ordinary-session", + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": embedded_request, + "accumulatedOutputTokens": 5, + "accumulatedCachedInputTokens": null, + "accumulatedCost": null + } + } + })); + }); + + let output = String::from_utf8(trace.0.lock().expect("trace buffer lock").clone()) + .expect("trace output should be UTF-8"); + assert!(!output.contains(secret)); + assert!(output.contains(REDACTED_ENV_VALUE)); + client.shutdown().await; + } + + #[tokio::test] + async fn structured_mcp_servers_survive_repeated_sessions_and_adapter_restart() { + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":2,"agentCapabilities":{}}}' + read -t 2 FIRST + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_first","_receivedRequest":'"$FIRST"'}}' + read -t 2 SECOND + echo '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses_second","_receivedRequest":'"$SECOND"'}}' + sleep 1 + "#; + let servers = vec![ + McpServer { + name: "analytics".into(), + command: "/opt/MCP Servers/analytics,prod".into(), + args: vec!["--stdio".into(), "literal value".into()], + env: vec![EnvVar { + name: "ANALYTICS_ENDPOINT".into(), + value: "https://example.test/a=b".into(), + }], + }, + McpServer { + name: "search".into(), + command: "/opt/search-mcp".into(), + args: vec![], + env: vec![], + }, + ]; + + for _restart in 0..2 { + let mut client = spawn_script(script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + client + .initialize() + .await + .expect("initialize should succeed"); + + for expected_session_id in ["ses_first", "ses_second"] { + let response = client + .session_new_full("/tmp", servers.clone(), None, None) + .await + .expect("session/new should succeed"); + assert_eq!(response.session_id, expected_session_id); + let received = &response.raw["_receivedRequest"]["params"]["mcpServers"]; + assert_eq!(received[0]["name"], "analytics"); + assert_eq!(received[0]["command"], "/opt/MCP Servers/analytics,prod"); + assert_eq!( + received[0]["args"], + serde_json::json!(["--stdio", "literal value"]) + ); + assert_eq!(received[0]["env"][0]["name"], "ANALYTICS_ENDPOINT"); + assert_eq!(received[0]["env"][0]["value"], "https://example.test/a=b"); + assert_eq!(received[1]["name"], "search"); + assert_eq!(received[1]["command"], "/opt/search-mcp"); + } + + let writes = observer + .snapshot() + .into_iter() + .filter(|event| { + event.kind == "acp_write" && event.payload["method"] == "session/new" + }) + .collect::>(); + assert_eq!(writes.len(), 2); + for write in writes { + let sent = &write.payload["params"]["mcpServers"]; + assert_eq!(sent[0]["name"], "analytics"); + assert_eq!(sent[0]["command"], "/opt/MCP Servers/analytics,prod"); + assert_eq!( + sent[0]["args"], + serde_json::json!(["--stdio", "literal value"]) + ); + assert_eq!(sent[0]["env"][0]["name"], "ANALYTICS_ENDPOINT"); + assert_eq!(sent[0]["env"][0]["value"], REDACTED_ENV_VALUE); + assert_eq!(sent[1]["name"], "search"); + assert_eq!(sent[1]["command"], "/opt/search-mcp"); + } + client.shutdown().await; + } + } + #[tokio::test] async fn goose_system_prompt_request_uses_append_contract() { let script = r#" @@ -4249,6 +4846,26 @@ mod tests { } } + #[test] + fn agent_error_from_json_redacts_mcp_env_before_display_or_turn_error() { + let secret = "agent-error-secret"; + let error = serde_json::json!({ + "code": -32002, + "data": { + "env": [{ + "name": "ANALYTICS_TOKEN", + "value": secret + }] + } + }); + + let rendered = super::agent_error_from_json(&error).to_string(); + + assert!(!rendered.contains(secret)); + assert!(rendered.contains(REDACTED_ENV_VALUE)); + assert_eq!(error["data"]["env"][0]["value"], secret); + } + #[test] fn agent_error_from_json_uses_message_field_when_present() { let error = serde_json::json!({"code": -32001, "message": "auth denied"}); diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index dab61be30a..e93342bf19 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -3,7 +3,8 @@ //! CLI-first: every option is a CLI flag with env var fallback. //! Config file (TOML) for complex subscription rules. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::Read; use std::path::PathBuf; use clap::Parser; @@ -47,6 +48,250 @@ pub enum ConfigError { ConfigFile(String), } +const MCP_CONFIG_VERSION: u32 = 1; +const MCP_CONFIG_MAX_BYTES: u64 = 64 * 1024; +const MCP_SERVER_MAX_COUNT: usize = 16; +const MCP_SERVER_MAX_ARGS: usize = 128; +const MCP_SERVER_MAX_ENV: usize = 128; +const MCP_SERVER_NAME_MAX_BYTES: usize = 128; +const PROTECTED_MCP_ENV_NAMES: [&str; 6] = [ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", +]; + +/// One local stdio MCP server loaded from the structured MCP configuration. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfiguredMcpServer { + /// Stable ACP identifier for this server. + pub name: String, + /// Executable to invoke, passed directly without shell parsing. + pub command: String, + /// Arguments passed to the executable in their configured order. + pub args: Vec, + /// Server-specific environment in deterministic key order. + #[serde(deserialize_with = "deserialize_mcp_env")] + pub env: BTreeMap, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct McpConfigDocument { + version: u32, + servers: Vec, +} + +fn deserialize_mcp_env<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct EnvVisitor; + + impl<'de> serde::de::Visitor<'de> for EnvVisitor { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("an object containing unique environment variable names") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut env = BTreeMap::new(); + let mut normalized_names = HashSet::new(); + while let Some((key, value)) = map.next_entry::()? { + if !normalized_names.insert(key.to_ascii_uppercase()) { + return Err(serde::de::Error::custom(format!( + "duplicate environment key '{key}'" + ))); + } + env.insert(key, value); + } + Ok(env) + } + } + + deserializer.deserialize_map(EnvVisitor) +} + +/// Derive the ACP name used by the legacy single-command MCP configuration. +/// +/// This preserves the existing `build_mcp_servers` behavior so collision +/// validation and runtime construction use the same name. +pub fn legacy_mcp_server_name(command: &str) -> String { + std::path::Path::new(command) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("mcp") + .to_string() +} + +fn read_mcp_config(path: &std::path::Path) -> Result, ConfigError> { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(nix::libc::O_NONBLOCK); + } + let file = options.open(path).map_err(|error| { + ConfigError::ConfigFile(format!( + "failed to open MCP config {}: {error}", + path.display() + )) + })?; + let metadata = file.metadata().map_err(|error| { + ConfigError::ConfigFile(format!( + "failed to inspect MCP config {}: {error}", + path.display() + )) + })?; + if !metadata.is_file() { + return Err(ConfigError::ConfigFile(format!( + "MCP config {} must be a regular file", + path.display() + ))); + } + let mut content = Vec::new(); + file.take(MCP_CONFIG_MAX_BYTES + 1) + .read_to_end(&mut content) + .map_err(|error| { + ConfigError::ConfigFile(format!( + "failed to read MCP config {}: {error}", + path.display() + )) + })?; + if content.len() as u64 > MCP_CONFIG_MAX_BYTES { + return Err(ConfigError::ConfigFile(format!( + "MCP config {} exceeds the {} byte limit", + path.display(), + MCP_CONFIG_MAX_BYTES + ))); + } + Ok(content) +} + +fn valid_mcp_server_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= MCP_SERVER_NAME_MAX_BYTES + && !name.contains("__") + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn valid_mcp_env_name(name: &str) -> bool { + let mut bytes = name.bytes(); + matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +fn load_mcp_config( + path: &std::path::Path, + legacy_mcp_command: &str, +) -> Result, ConfigError> { + let content = read_mcp_config(path)?; + let document: McpConfigDocument = serde_json::from_slice(&content).map_err(|error| { + ConfigError::ConfigFile(format!("invalid MCP config {}: {error}", path.display())) + })?; + + if document.version != MCP_CONFIG_VERSION { + return Err(ConfigError::ConfigFile(format!( + "unsupported MCP config version {} (expected {})", + document.version, MCP_CONFIG_VERSION + ))); + } + + let legacy_count = usize::from(!legacy_mcp_command.is_empty()); + if document.servers.len() + legacy_count > MCP_SERVER_MAX_COUNT { + return Err(ConfigError::ConfigFile(format!( + "too many MCP servers ({} structured + {legacy_count} legacy, max {MCP_SERVER_MAX_COUNT})", + document.servers.len() + ))); + } + + let legacy_name = + (!legacy_mcp_command.is_empty()).then(|| legacy_mcp_server_name(legacy_mcp_command)); + let mut names = HashSet::with_capacity(document.servers.len()); + for (index, server) in document.servers.iter().enumerate() { + if !valid_mcp_server_name(&server.name) { + return Err(ConfigError::ConfigFile(format!( + "MCP server {} has invalid name '{}': use 1 to {MCP_SERVER_NAME_MAX_BYTES} ASCII letters, digits, underscores, or hyphens, without '__'", + index + 1, + server.name + ))); + } + if !names.insert(server.name.as_str()) { + return Err(ConfigError::ConfigFile(format!( + "duplicate MCP server name '{}'", + server.name + ))); + } + if legacy_name.as_deref() == Some(server.name.as_str()) { + return Err(ConfigError::ConfigFile(format!( + "MCP server name '{}' collides with the legacy --mcp-command server", + server.name + ))); + } + if server.command.is_empty() || server.command.contains('\0') { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' command must be nonempty and contain no NUL bytes", + server.name + ))); + } + if server.args.len() > MCP_SERVER_MAX_ARGS { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' has too many arguments ({}, max {MCP_SERVER_MAX_ARGS})", + server.name, + server.args.len() + ))); + } + if server.args.iter().any(|argument| argument.contains('\0')) { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' arguments must contain no NUL bytes", + server.name + ))); + } + if server.env.len() > MCP_SERVER_MAX_ENV { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' has too many environment entries ({}, max {MCP_SERVER_MAX_ENV})", + server.name, + server.env.len() + ))); + } + for (key, value) in &server.env { + if !valid_mcp_env_name(key) { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' has invalid environment key '{key}'", + server.name + ))); + } + if PROTECTED_MCP_ENV_NAMES + .iter() + .any(|protected| key.eq_ignore_ascii_case(protected)) + { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' may not configure protected environment key '{key}'", + server.name + ))); + } + if value.contains('\0') { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' environment value for '{key}' contains a NUL byte", + server.name + ))); + } + } + } + + Ok(document.servers) +} + #[derive(Debug, Clone, PartialEq, clap::ValueEnum)] pub enum SubscribeMode { Mentions, @@ -261,6 +506,10 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_MCP_COMMAND", default_value = "")] pub mcp_command: String, + /// Path to a versioned JSON document defining additional local MCP servers. + #[arg(long, env = "BUZZ_ACP_MCP_CONFIG")] + pub mcp_config: Option, + /// Idle timeout: max seconds of silence before killing a turn. /// Resets on any agent stdout activity. #[arg(long, env = "BUZZ_ACP_IDLE_TIMEOUT")] @@ -495,6 +744,8 @@ pub struct Config { pub agent_command: String, pub agent_args: Vec, pub mcp_command: String, + /// Additional local MCP servers loaded once from `--mcp-config`. + pub configured_mcp_servers: Vec, pub idle_timeout_secs: u64, pub max_turn_duration_secs: u64, pub agents: u32, @@ -820,10 +1071,23 @@ pub fn propagate_legacy_env_vars() { } } +/// Prepare environment fallbacks before Clap and Tokio read process state. +/// +/// Deployment templates commonly render optional values as empty strings. +/// Clap treats an empty value for `Option` as an invalid supplied +/// value, so normalize this one optional path to the same state as an unset +/// variable before argument parsing starts. +pub fn prepare_process_env() { + propagate_legacy_env_vars(); + if std::env::var_os("BUZZ_ACP_MCP_CONFIG").is_some_and(|value| value.is_empty()) { + std::env::remove_var("BUZZ_ACP_MCP_CONFIG"); + } +} + impl Config { pub fn from_cli() -> Result { // Legacy env-var propagation is intentionally NOT done here. - // Call `propagate_legacy_env_vars()` before the tokio runtime starts + // Call `prepare_process_env()` before the tokio runtime starts // (in the sync `fn main()` wrapper) — see Rust 2024 edition safety. let args = CliArgs::parse(); Self::from_args(args) @@ -906,6 +1170,10 @@ impl Config { } let agent_args = normalize_agent_args(&agent_command, args.agent_args); + let configured_mcp_servers = match args.mcp_config.as_deref() { + Some(path) => load_mcp_config(path, &args.mcp_command)?, + None => Vec::new(), + }; if let Some(ref channels) = args.channels { for ch in channels { @@ -1059,6 +1327,7 @@ impl Config { agent_command, agent_args, mcp_command: args.mcp_command, + configured_mcp_servers, idle_timeout_secs, max_turn_duration_secs, agents: args.agents, @@ -1123,12 +1392,13 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + "relay={} pubkey={} agent_cmd={} {} legacy_mcp_server={} structured_mcp_servers={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, self.agent_args.join(" "), - self.mcp_command, + !self.mcp_command.is_empty(), + self.configured_mcp_servers.len(), self.idle_timeout_secs, self.max_turn_duration_secs, self.agents, @@ -1437,6 +1707,7 @@ mod tests { agent_command: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "".into(), + configured_mcp_servers: Vec::new(), idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, @@ -2899,6 +3170,414 @@ channels = "ALL" assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent); } + struct TempMcpConfig { + path: PathBuf, + } + + impl TempMcpConfig { + fn write(content: &[u8]) -> Self { + let path = + std::env::temp_dir().join(format!("buzz-acp-mcp-config-{}.json", Uuid::new_v4())); + std::fs::write(&path, content).expect("write temporary MCP config"); + Self { path } + } + } + + impl Drop for TempMcpConfig { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } + + fn config_from_mcp_file( + file: &TempMcpConfig, + legacy_command: Option<&str>, + ) -> Result { + let mut argv = vec![ + "buzz-acp".to_string(), + "--private-key".to_string(), + TEST_PRIVATE_KEY.to_string(), + "--mcp-config".to_string(), + file.path.display().to_string(), + ]; + if let Some(command) = legacy_command { + argv.push("--mcp-command".to_string()); + argv.push(command.to_string()); + } + let args = CliArgs::try_parse_from(argv).expect("clap should parse MCP config arguments"); + Config::from_args(args) + } + + fn server_json(name: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "command": "mcp", + "args": [], + "env": {} + }) + } + + fn document_json(servers: Vec) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "version": MCP_CONFIG_VERSION, + "servers": servers + })) + .expect("serialize MCP test document") + } + + #[test] + fn structured_mcp_config_preserves_order_and_exact_values() { + let posix_command = "/Applications/Tool Suite/工具 mcp"; + let windows_command = r"C:\Program Files\Agent Tools\server.exe"; + let literal_metacharacters = r#"$HOME;$(echo nope)|&<>*?`literal`"#; + let file = TempMcpConfig::write(&document_json(vec![ + serde_json::json!({ + "name": "analytics-primary", + "command": posix_command, + "args": [ + "", + "with spaces", + "comma,value", + "quote\"value", + r"C:\data\reports", + literal_metacharacters, + "雪" + ], + "env": { + "Z_LAST": "backslash\\quote\"雪", + "A_FIRST": "" + } + }), + serde_json::json!({ + "name": "windows_server", + "command": windows_command, + "args": ["--stdio"], + "env": {} + }), + ])); + + let config = config_from_mcp_file(&file, None).expect("structured config should load"); + assert_eq!(config.configured_mcp_servers.len(), 2); + assert_eq!(config.configured_mcp_servers[0].name, "analytics-primary"); + assert_eq!(config.configured_mcp_servers[0].command, posix_command); + assert_eq!( + config.configured_mcp_servers[0].args, + vec![ + "", + "with spaces", + "comma,value", + "quote\"value", + r"C:\data\reports", + literal_metacharacters, + "雪" + ] + ); + assert_eq!( + config.configured_mcp_servers[0] + .env + .keys() + .map(String::as_str) + .collect::>(), + vec!["A_FIRST", "Z_LAST"] + ); + assert_eq!( + config.configured_mcp_servers[0].env["Z_LAST"], + "backslash\\quote\"雪" + ); + assert_eq!(config.configured_mcp_servers[1].command, windows_command); + } + + #[test] + fn summary_reports_only_structured_mcp_count() { + let secret_value = "value-that-must-not-be-logged"; + let command = "/private/path/tool"; + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "safe", + "command": command, + "args": [], + "env": {"DOMAIN_TOKEN": secret_value} + })])); + let config = + config_from_mcp_file(&file, Some("/legacy/private/tool")).expect("config should load"); + + let summary = config.summary(); + assert!(summary.contains("legacy_mcp_server=true")); + assert!(summary.contains("structured_mcp_servers=1")); + assert!(!summary.contains(secret_value)); + assert!(!summary.contains(command)); + assert!(!summary.contains("/legacy/private/tool")); + assert!(!summary.contains("DOMAIN_TOKEN")); + } + + #[test] + fn legacy_mcp_name_matches_existing_file_stem_behavior() { + assert_eq!( + legacy_mcp_server_name("/opt/bin/my-mcp-server"), + "my-mcp-server" + ); + assert_eq!(legacy_mcp_server_name("."), "mcp"); + assert_eq!(legacy_mcp_server_name(""), "mcp"); + } + + #[test] + fn mcp_config_rejects_unreadable_file() { + let path = std::env::temp_dir().join(format!( + "buzz-acp-missing-mcp-config-{}.json", + Uuid::new_v4() + )); + let argv = vec![ + "buzz-acp".to_string(), + "--private-key".to_string(), + TEST_PRIVATE_KEY.to_string(), + "--mcp-config".to_string(), + path.display().to_string(), + ]; + let args = CliArgs::try_parse_from(argv).expect("clap should parse arguments"); + let error = Config::from_args(args).expect_err("missing MCP config must fail"); + assert!(error.to_string().contains("failed to open MCP config")); + } + + #[test] + fn mcp_config_rejects_non_regular_files() { + let path = std::env::temp_dir().join(format!("buzz-acp-mcp-config-dir-{}", Uuid::new_v4())); + std::fs::create_dir(&path).expect("create temporary MCP config directory"); + + let error = read_mcp_config(&path).expect_err("directories must not be read as MCP config"); + let _ = std::fs::remove_dir(&path); + assert!(error.to_string().contains("must be a regular file")); + } + + #[test] + fn mcp_config_enforces_file_size_boundary() { + let base = document_json(Vec::new()); + let mut at_limit = base.clone(); + at_limit.resize(MCP_CONFIG_MAX_BYTES as usize, b' '); + let file = TempMcpConfig::write(&at_limit); + config_from_mcp_file(&file, None).expect("64 KiB MCP config should be accepted"); + + let mut over_limit = base; + over_limit.resize(MCP_CONFIG_MAX_BYTES as usize + 1, b' '); + let file = TempMcpConfig::write(&over_limit); + let error = + config_from_mcp_file(&file, None).expect_err("MCP config over 64 KiB must fail"); + assert!(error.to_string().contains("65536 byte limit")); + } + + #[test] + fn mcp_config_rejects_malformed_wrong_version_and_unknown_fields() { + let cases: Vec<(&str, Vec)> = vec![ + ("malformed", br#"{"version":1,"servers":["#.to_vec()), + ( + "wrong version", + br#"{"version":2,"servers":[]}"#.to_vec(), + ), + ( + "unknown document field", + br#"{"version":1,"servers":[],"extra":true}"#.to_vec(), + ), + ( + "unknown server field", + br#"{"version":1,"servers":[{"name":"one","command":"mcp","args":[],"env":{},"extra":true}]}"#.to_vec(), + ), + ( + "missing required field", + br#"{"version":1,"servers":[{"name":"one","command":"mcp","env":{}}]}"#.to_vec(), + ), + ]; + + for (label, content) in cases { + let file = TempMcpConfig::write(&content); + assert!( + config_from_mcp_file(&file, None).is_err(), + "{label} should be rejected" + ); + } + } + + #[test] + fn mcp_config_validates_server_names_and_collisions() { + let invalid_names = vec![ + String::new(), + "contains space".to_string(), + "contains.dot".to_string(), + "double__underscore".to_string(), + "unicodé".to_string(), + "a".repeat(MCP_SERVER_NAME_MAX_BYTES + 1), + ]; + for invalid_name in invalid_names { + let file = TempMcpConfig::write(&document_json(vec![server_json(&invalid_name)])); + assert!( + config_from_mcp_file(&file, None).is_err(), + "invalid name {invalid_name:?} should fail" + ); + } + + let file = TempMcpConfig::write(&document_json(vec![ + server_json("same"), + server_json("same"), + ])); + let error = + config_from_mcp_file(&file, None).expect_err("duplicate server name should fail"); + assert!(error.to_string().contains("duplicate MCP server name")); + + let file = TempMcpConfig::write(&document_json(vec![server_json("my-mcp-server")])); + let error = config_from_mcp_file(&file, Some("/opt/bin/my-mcp-server")) + .expect_err("legacy name collision should fail"); + assert!(error.to_string().contains("collides")); + } + + #[test] + fn mcp_config_limits_total_servers_including_legacy() { + let sixteen = (0..MCP_SERVER_MAX_COUNT) + .map(|index| server_json(&format!("server-{index}"))) + .collect::>(); + let file = TempMcpConfig::write(&document_json(sixteen)); + config_from_mcp_file(&file, None).expect("16 structured servers should be accepted"); + assert!( + config_from_mcp_file(&file, Some("legacy-mcp")).is_err(), + "16 structured plus one legacy server should fail" + ); + + let seventeen = (0..=MCP_SERVER_MAX_COUNT) + .map(|index| server_json(&format!("server-{index}"))) + .collect::>(); + let file = TempMcpConfig::write(&document_json(seventeen)); + assert!( + config_from_mcp_file(&file, None).is_err(), + "17 structured servers should fail" + ); + } + + #[test] + fn mcp_config_enforces_argument_limit() { + let args = (0..MCP_SERVER_MAX_ARGS) + .map(|index| format!("arg-{index}")) + .collect::>(); + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "limit", + "command": "mcp", + "args": args, + "env": {} + })])); + config_from_mcp_file(&file, None).expect("128 arguments should be accepted"); + + let args = (0..=MCP_SERVER_MAX_ARGS) + .map(|index| format!("arg-{index}")) + .collect::>(); + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "over-limit", + "command": "mcp", + "args": args, + "env": {} + })])); + let error = + config_from_mcp_file(&file, None).expect_err("129 arguments should be rejected"); + assert!(error.to_string().contains("too many arguments")); + } + + #[test] + fn mcp_config_enforces_environment_limit() { + let env = (0..MCP_SERVER_MAX_ENV) + .map(|index| (format!("KEY_{index}"), format!("value-{index}"))) + .collect::>(); + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "limit", + "command": "mcp", + "args": [], + "env": env + })])); + config_from_mcp_file(&file, None).expect("128 environment entries should be accepted"); + + let env = (0..=MCP_SERVER_MAX_ENV) + .map(|index| (format!("KEY_{index}"), format!("value-{index}"))) + .collect::>(); + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "over-limit", + "command": "mcp", + "args": [], + "env": env + })])); + let error = config_from_mcp_file(&file, None) + .expect_err("129 environment entries should be rejected"); + assert!(error.to_string().contains("too many environment entries")); + } + + #[test] + fn mcp_config_rejects_invalid_duplicate_and_protected_env_names() { + for invalid_key in ["", "1STARTS_WITH_DIGIT", "BAD-NAME", "UNICODÉ"] { + let content = format!( + r#"{{"version":1,"servers":[{{"name":"one","command":"mcp","args":[],"env":{{"{invalid_key}":"value"}}}}]}}"# + ); + let file = TempMcpConfig::write(content.as_bytes()); + assert!( + config_from_mcp_file(&file, None).is_err(), + "invalid environment key {invalid_key:?} should fail" + ); + } + + for protected in PROTECTED_MCP_ENV_NAMES { + let lowercase = protected.to_ascii_lowercase(); + let content = format!( + r#"{{"version":1,"servers":[{{"name":"one","command":"mcp","args":[],"env":{{"{lowercase}":"value"}}}}]}}"# + ); + let file = TempMcpConfig::write(content.as_bytes()); + let error = config_from_mcp_file(&file, None) + .expect_err("protected environment key should fail case-insensitively"); + assert!(error.to_string().contains("protected environment key")); + } + + for duplicate_env in [ + r#"{"KEY":"one","KEY":"two"}"#, + r#"{"KEY":"one","key":"two"}"#, + ] { + let content = format!( + r#"{{"version":1,"servers":[{{"name":"one","command":"mcp","args":[],"env":{duplicate_env}}}]}}"# + ); + let file = TempMcpConfig::write(content.as_bytes()); + let error = + config_from_mcp_file(&file, None).expect_err("duplicate env key should fail"); + assert!(error.to_string().contains("duplicate environment key")); + } + } + + #[test] + fn mcp_config_rejects_empty_or_nul_process_values() { + let cases = vec![ + serde_json::json!({ + "name": "empty-command", + "command": "", + "args": [], + "env": {} + }), + serde_json::json!({ + "name": "nul-command", + "command": "mc\u{0}p", + "args": [], + "env": {} + }), + serde_json::json!({ + "name": "nul-arg", + "command": "mcp", + "args": ["ok", "bad\u{0}arg"], + "env": {} + }), + serde_json::json!({ + "name": "nul-value", + "command": "mcp", + "args": [], + "env": {"DOMAIN_KEY": "bad\u{0}value"} + }), + ]; + + for server in cases { + let file = TempMcpConfig::write(&document_json(vec![server])); + assert!( + config_from_mcp_file(&file, None).is_err(), + "invalid process value should fail" + ); + } + } + /// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH /// must set `hide_env_values = true` to prevent credential leakage in --help. #[test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322..10b2866586 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1229,7 +1229,7 @@ impl Drop for RespawnGuard { // worker threads (Rust 2024 edition safety requirement). pub fn run() -> Result<()> { - config::propagate_legacy_env_vars(); + config::prepare_process_env(); tokio_main() } @@ -4177,60 +4177,77 @@ async fn run_models(args: ModelsArgs) -> Result<()> { } fn build_mcp_servers(config: &Config) -> Vec { - if config.mcp_command.is_empty() { - return vec![]; - } - vec![McpServer { - name: std::path::Path::new(&config.mcp_command) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("mcp") - .to_string(), - command: config.mcp_command.clone(), - args: vec![], - env: { - let mut env = vec![ - EnvVar { - name: "BUZZ_RELAY_URL".into(), - value: config.relay_url.clone(), - }, - EnvVar { - name: "BUZZ_PRIVATE_KEY".into(), - // bech32 encoding of a valid secret key is infallible. - // Panic here is correct: injecting a bogus secret would cause - // delayed, hard-to-diagnose agent failures downstream. - value: config - .keys - .secret_key() - .to_bech32() - .expect("secret key bech32 encoding should never fail"), - }, - ]; - // Forward BUZZ_AUTH_TAG (NIP-OA owner attestation credential) - // so the MCP server can attach it to every signed event. - if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { - if !auth_tag.is_empty() { - env.push(EnvVar { - name: "BUZZ_AUTH_TAG".into(), - value: auth_tag, - }); + let mut servers = Vec::with_capacity( + usize::from(!config.mcp_command.is_empty()) + config.configured_mcp_servers.len(), + ); + + if !config.mcp_command.is_empty() { + servers.push(McpServer { + name: config::legacy_mcp_server_name(&config.mcp_command), + command: config.mcp_command.clone(), + args: vec![], + env: { + let mut env = vec![ + EnvVar { + name: "BUZZ_RELAY_URL".into(), + value: config.relay_url.clone(), + }, + EnvVar { + name: "BUZZ_PRIVATE_KEY".into(), + // bech32 encoding of a valid secret key is infallible. + // Panic here is correct: injecting a bogus secret would cause + // delayed, hard-to-diagnose agent failures downstream. + value: config + .keys + .secret_key() + .to_bech32() + .expect("secret key bech32 encoding should never fail"), + }, + ]; + // Forward BUZZ_AUTH_TAG (NIP-OA owner attestation credential) + // so the MCP server can attach it to every signed event. + if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { + if !auth_tag.is_empty() { + env.push(EnvVar { + name: "BUZZ_AUTH_TAG".into(), + value: auth_tag, + }); + } } - } - // Forward the agent's display name so dev-mcp can use it as the git - // author name instead of the raw npub. Read from the process env - // rather than Config: this is a pass-through of a contract owned - // upstream, and absent simply means dev-mcp falls back to the npub. - if let Ok(display_name) = std::env::var("BUZZ_ACP_DISPLAY_NAME") { - if !display_name.is_empty() { - env.push(EnvVar { - name: "BUZZ_ACP_DISPLAY_NAME".into(), - value: display_name, - }); + // Forward the agent's display name so dev-mcp can use it as the git + // author name instead of the raw npub. Read from the process env + // rather than Config: this is a pass-through of a contract owned + // upstream, and absent simply means dev-mcp falls back to the npub. + if let Ok(display_name) = std::env::var("BUZZ_ACP_DISPLAY_NAME") { + if !display_name.is_empty() { + env.push(EnvVar { + name: "BUZZ_ACP_DISPLAY_NAME".into(), + value: display_name, + }); + } } - } - env - }, - }] + env + }, + }); + } + + servers.extend(config.configured_mcp_servers.iter().map(|configured| { + McpServer { + name: configured.name.clone(), + command: configured.command.clone(), + args: configured.args.clone(), + env: configured + .env + .iter() + .map(|(name, value)| EnvVar { + name: name.clone(), + value: value.clone(), + }) + .collect(), + } + })); + + servers } #[cfg(test)] @@ -4988,6 +5005,8 @@ mod observer_chunk_coalescer_tests { #[cfg(test)] mod build_mcp_servers_tests { use super::*; + use clap::Parser; + use std::collections::BTreeMap; use std::sync::Mutex; /// Env-var-touching tests must run serially — env vars are process-global. @@ -5000,6 +5019,7 @@ mod build_mcp_servers_tests { agent_command: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "test-mcp-server".into(), + configured_mcp_servers: Vec::new(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, @@ -5038,6 +5058,44 @@ mod build_mcp_servers_tests { } } + fn configured_server( + name: &str, + command: &str, + args: &[&str], + env: &[(&str, &str)], + ) -> config::ConfiguredMcpServer { + config::ConfiguredMcpServer { + name: name.into(), + command: command.into(), + args: args.iter().map(|arg| (*arg).to_string()).collect(), + env: env + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect::>(), + } + } + + struct TempMcpConfig { + path: std::path::PathBuf, + } + + impl TempMcpConfig { + fn write(content: &[u8]) -> Self { + let path = std::env::temp_dir().join(format!( + "buzz-acp-mcp-build-test-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, content).expect("write temporary MCP config"); + Self { path } + } + } + + impl Drop for TempMcpConfig { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } + #[test] fn session_new_mcp_server_has_required_fields() { let config = test_config(); @@ -5152,6 +5210,167 @@ mod build_mcp_servers_tests { ); } + #[test] + fn structured_servers_preserve_order_and_literal_values_without_legacy_credentials() { + let mut config = test_config(); + config.mcp_command.clear(); + config.configured_mcp_servers = vec![ + configured_server( + "analytics", + "/opt/MCP Servers/analytics,prod", + &[ + "--stdio", + "two words", + "comma,value", + "\"quoted\"", + r"C:\Program Files\MCP\server.exe", + "雪", + "$(literal)", + "`literal`", + "a|b;c", + ], + &[ + ("ANALYTICS_ENDPOINT", "https://example.test/a=b"), + ("LITERAL_VALUE", "$HOME;`id`|雪"), + ], + ), + configured_server("search", "/opt/search-mcp", &[], &[]), + ]; + + let servers = build_mcp_servers(&config); + + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].name, "analytics"); + assert_eq!(servers[1].name, "search"); + assert_eq!(servers[0].command, "/opt/MCP Servers/analytics,prod"); + assert_eq!( + servers[0].args, + vec![ + "--stdio", + "two words", + "comma,value", + "\"quoted\"", + r"C:\Program Files\MCP\server.exe", + "雪", + "$(literal)", + "`literal`", + "a|b;c", + ] + ); + assert_eq!( + servers[0] + .env + .iter() + .map(|entry| (entry.name.as_str(), entry.value.as_str())) + .collect::>(), + vec![ + ("ANALYTICS_ENDPOINT", "https://example.test/a=b"), + ("LITERAL_VALUE", "$HOME;`id`|雪"), + ] + ); + assert!( + servers[0].env.iter().all(|entry| !matches!( + entry.name.as_str(), + "BUZZ_PRIVATE_KEY" | "BUZZ_AUTH_TAG" | "BUZZ_RELAY_URL" + )), + "structured servers must receive only their declared environment" + ); + } + + #[test] + fn legacy_server_remains_first_when_structured_servers_are_present() { + let mut config = test_config(); + config.configured_mcp_servers = vec![configured_server( + "analytics", + "/opt/analytics-mcp", + &["--stdio"], + &[("ANALYTICS_TOKEN", "opaque-test-token")], + )]; + + let servers = build_mcp_servers(&config); + + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].name, "test-mcp-server"); + assert_eq!(servers[1].name, "analytics"); + assert!(servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_PRIVATE_KEY")); + assert_eq!( + servers[1] + .env + .iter() + .map(|entry| (entry.name.as_str(), entry.value.as_str())) + .collect::>(), + vec![("ANALYTICS_TOKEN", "opaque-test-token")] + ); + } + + #[test] + fn structured_json_reaches_initial_repeated_and_respawn_session_lists() { + let document = serde_json::json!({ + "version": 1, + "servers": [ + { + "name": "analytics", + "command": "/opt/MCP Servers/analytics,prod", + "args": ["--stdio", "literal value"], + "env": { + "ANALYTICS_ENDPOINT": "https://example.test/a=b" + } + }, + { + "name": "search", + "command": "/opt/search-mcp", + "args": [], + "env": {} + } + ] + }); + let file = TempMcpConfig::write( + &serde_json::to_vec(&document).expect("serialize MCP config fixture"), + ); + let private_key = nostr::Keys::generate() + .secret_key() + .to_bech32() + .expect("encode temporary private key"); + let args = config::CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + private_key.as_str(), + "--mcp-command", + "/opt/buzz-dev-mcp", + "--mcp-config", + file.path.to_str().expect("temporary path is UTF-8"), + ]) + .expect("parse MCP arguments"); + let config = Config::from_args(args).expect("load structured MCP config"); + + let initial_session = build_mcp_servers(&config); + let repeated_session = initial_session.clone(); + let respawned_session = repeated_session.clone(); + let initial_json = + serde_json::to_value(&initial_session).expect("serialize initial MCP list"); + let repeated_json = + serde_json::to_value(&repeated_session).expect("serialize repeated MCP list"); + let respawned_json = + serde_json::to_value(&respawned_session).expect("serialize respawned MCP list"); + + assert_eq!(initial_json, repeated_json); + assert_eq!(initial_json, respawned_json); + assert_eq!(initial_json.as_array().map(Vec::len), Some(3)); + assert_eq!(initial_json[0]["name"], "buzz-dev-mcp"); + assert_eq!(initial_json[1]["name"], "analytics"); + assert_eq!(initial_json[2]["name"], "search"); + assert_eq!( + initial_json[1]["env"], + serde_json::json!([{ + "name": "ANALYTICS_ENDPOINT", + "value": "https://example.test/a=b" + }]) + ); + } + #[test] fn absolute_path_mcp_command_uses_file_stem_as_name() { let mut config = test_config(); @@ -5221,6 +5440,7 @@ mod error_outcome_emission_tests { agent_command: "true".into(), agent_args: vec![], mcp_command: "test-mcp-server".into(), + configured_mcp_servers: Vec::new(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index d1e005cbcc..348bc138e4 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -19,6 +19,7 @@ //! //! `AcpClient` is NOT Clone — ownership moves out on claim and back on return. +use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -800,6 +801,9 @@ pub enum IdleSwitchResult { /// 2 × CONTEXT_FETCH_TIMEOUT + CONTEXT_FETCH_RETRY_DELAY ≈ 6.5 s. const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_millis(3_000); +/// Short, single-attempt timeout for best-effort exact truncated-thread counts. +const CONTEXT_COUNT_TIMEOUT: Duration = Duration::from_millis(500); + /// Delay between the first failed context fetch and the single retry. const CONTEXT_FETCH_RETRY_DELAY: Duration = Duration::from_millis(500); @@ -2600,7 +2604,14 @@ async fn fetch_conversation_context( let last_event = batch.events.last()?; let tags = crate::queue::parse_thread_tags(&last_event.event); if let Some(root_id) = tags.root_event_id { - return fetch_thread_context(batch.channel_id, &root_id, limit, &ctx.rest_client).await; + return fetch_thread_context( + batch.channel_id, + &root_id, + limit, + ctx.agent_keys.public_key(), + &ctx.rest_client, + ) + .await; } // DM non-reply: fetch recent conversation history. @@ -2762,12 +2773,48 @@ async fn fetch_prompt_profile_lookup( } /// Fetch thread context via Nostr query: root event by ID + replies by `#e` tag. +/// +/// The reply query intentionally requests one more reply than the configured +/// display window. That sentinel event lets the prompt say `N of M, truncated` +/// when the relay has more thread history, instead of reporting the capped page +/// as the total. When the window is full, a best-effort `/count` attempts to +/// improve that lower-bound total; because it is a separate racy request, the +/// result is clamped to the sentinel-proven minimum. The query also asks for the +/// agent's newest reply separately so the next prompt can include the agent's +/// own prior turn even in busy threads where the recent-message window would +/// otherwise push it out. async fn fetch_thread_context( channel_id: Uuid, root_event_id: &str, limit: u32, + agent_pubkey: nostr::PublicKey, rest: &RestClient, ) -> Option { + fetch_thread_context_with( + channel_id, + root_event_id, + limit, + agent_pubkey, + |filters| async move { rest.query(&filters).await }, + |filters| async move { rest.count(&filters).await }, + ) + .await +} + +async fn fetch_thread_context_with( + channel_id: Uuid, + root_event_id: &str, + limit: u32, + agent_pubkey: nostr::PublicKey, + query: Query, + count: Count, +) -> Option +where + Query: Fn(Vec) -> QueryFut, + QueryFut: std::future::Future>, + Count: Fn(Vec) -> CountFut, + CountFut: std::future::Future>, +{ use nostr::{Alphabet, SingleLetterTag}; // Defense-in-depth: validate hex event ID. @@ -2786,7 +2833,8 @@ async fn fetch_thread_context( let h_tag = SingleLetterTag::lowercase(Alphabet::H); let ch_str = channel_id.to_string(); - // Two filters: (1) root event by ID, (2) replies with #e=root + #h=channel. + // Three filters: (1) root event by ID, (2) recent replies with #e=root + + // #h=channel plus a sentinel, and (3) the agent's newest reply for pinning. let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?); let replies_filter = nostr::Filter::new() .kinds([ @@ -2795,16 +2843,23 @@ async fn fetch_thread_context( ]) .custom_tags(e_tag, [root_event_id]) .custom_tags(h_tag, [ch_str.as_str()]) - .limit(limit as usize); + .limit(limit.saturating_add(1) as usize); + let agent_reply_filter = replies_filter.clone().author(agent_pubkey).limit(1); - fetch_with_retry(|| async { + let context = fetch_with_retry(|| async { match timeout( CONTEXT_FETCH_TIMEOUT, - rest.query(&[root_filter.clone(), replies_filter.clone()]), + query(vec![ + root_filter.clone(), + replies_filter.clone(), + agent_reply_filter.clone(), + ]), ) .await { - Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id), + Ok(Ok(json)) => { + parse_nostr_thread_response_with_meta(json, root_event_id, limit, &agent_pubkey) + } Ok(Err(e)) => { tracing::warn!( channel_id = %channel_id, @@ -2823,7 +2878,75 @@ async fn fetch_thread_context( } } }) - .await + .await; + + let mut parsed = context?; + + if matches!( + parsed.context, + ConversationContext::Thread { + truncated: true, + .. + } + ) { + let replies_count_filter = replies_filter.clone().limit(0); + if let Some(total) = fetch_thread_total( + channel_id, + &replies_count_filter, + parsed.root_present, + &count, + ) + .await + { + if let ConversationContext::Thread { + total: context_total, + .. + } = &mut parsed.context + { + let sentinel_minimum = *context_total; + // `/count` is a separate best-effort request after the message + // query. If replies are deleted between the two, the exact count + // can fall below the already-proven sentinel minimum; never + // render impossible labels like `13 of 12 messages, truncated`. + *context_total = total.max(sentinel_minimum); + } + } + } + + Some(parsed.context) +} + +/// Best-effort exact thread size for truncated context labels. +async fn fetch_thread_total( + channel_id: Uuid, + replies_filter: &nostr::Filter, + root_present: bool, + count: &Count, +) -> Option +where + Count: Fn(Vec) -> CountFut, + CountFut: std::future::Future>, +{ + let replies_count = + match timeout(CONTEXT_COUNT_TIMEOUT, count(vec![replies_filter.clone()])).await { + Ok(Ok(json)) => json.get("count").and_then(|v| v.as_u64())?, + Ok(Err(e)) => { + tracing::debug!( + channel_id = %channel_id, + "thread context count failed; using sentinel minimum: {e}" + ); + return None; + } + Err(_) => { + tracing::debug!( + channel_id = %channel_id, + "thread context count timed out; using sentinel minimum" + ); + return None; + } + }; + + Some(replies_count as usize + usize::from(root_present)) } /// Fetch DM context via Nostr query: recent messages in channel by `#h` tag. @@ -2976,48 +3099,110 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option { /// Parse a Nostr query response (array of events) into thread context. /// -/// Separates the root event (matching `root_event_id`) from replies, sorts -/// chronologically by `created_at`. +/// Separates the root event (matching `root_event_id`) from replies, keeps the +/// newest `limit` replies returned by the sentinel query, then sorts the +/// displayed window chronologically for the prompt. If the agent's newest reply +/// is outside that window, keep it instead of the oldest displayed reply so the +/// next prompt always includes the agent's most recent prior turn. +#[cfg(test)] fn parse_nostr_thread_response( json: serde_json::Value, root_event_id: &str, + limit: u32, + agent_pubkey: &nostr::PublicKey, ) -> Option { + parse_nostr_thread_response_with_meta(json, root_event_id, limit, agent_pubkey) + .map(|parsed| parsed.context) +} + +struct ParsedThreadContext { + context: ConversationContext, + root_present: bool, +} + +fn parse_nostr_thread_response_with_meta( + json: serde_json::Value, + root_event_id: &str, + limit: u32, + agent_pubkey: &nostr::PublicKey, +) -> Option { let events = json.as_array()?; + let agent_pubkey_hex = agent_pubkey.to_hex(); let mut root_msg = None; let mut reply_msgs = Vec::new(); + let mut seen_reply_ids = HashSet::new(); for ev in events { let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or(""); if let Some(msg) = json_to_context_message(ev) { if ev_id == root_event_id { root_msg = Some(msg); - } else { + } else if seen_reply_ids.insert(ev_id.to_string()) { + let is_agent = msg.pubkey.eq_ignore_ascii_case(&agent_pubkey_hex); reply_msgs.push(( + ev_id.to_string(), ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + is_agent, msg, )); } } } - // Sort replies chronologically. - reply_msgs.sort_by_key(|(ts, _)| *ts); + let root_present = root_msg.is_some(); + let fetched_total = reply_msgs.len() + usize::from(root_present); + let newest_agent_reply = reply_msgs + .iter() + .filter(|(_, _, is_agent, _)| *is_agent) + .max_by_key(|(_, ts, _, _)| *ts) + .cloned(); + + let truncated = reply_msgs.len() > limit as usize; + if truncated { + // The relay returns limited REQ results newest-first. Sort explicitly so + // the sentinel we drop is the oldest reply in the fetched window, not an + // arbitrary last element if the HTTP bridge ever changes iteration order. + reply_msgs.sort_by_key(|(_, ts, _, _)| Reverse(*ts)); + reply_msgs.truncate(limit as usize); + } + + if let Some(agent_reply) = newest_agent_reply { + let agent_reply_already_displayed = + reply_msgs.iter().any(|(id, _, _, _)| *id == agent_reply.0); + if !agent_reply_already_displayed { + reply_msgs.sort_by_key(|(_, ts, _, _)| *ts); + if let Some(oldest) = reply_msgs.first_mut() { + *oldest = agent_reply; + } + } + } + + // Sort displayed replies chronologically. + reply_msgs.sort_by_key(|(_, ts, _, _)| *ts); let mut messages = Vec::new(); if let Some(root) = root_msg { messages.push(root); } - messages.extend(reply_msgs.into_iter().map(|(_, msg)| msg)); + messages.extend(reply_msgs.into_iter().map(|(_, _, _, msg)| msg)); - let total = messages.len(); if messages.is_empty() { return None; } - Some(ConversationContext::Thread { - messages, - total, - truncated: false, // query returns all within limit + let total = if truncated { + fetched_total // all distinct fetched replies plus the root are proven visible history + } else { + messages.len() + }; + + Some(ParsedThreadContext { + context: ConversationContext::Thread { + messages, + total, + truncated, + }, + root_present, }) } @@ -3439,7 +3624,11 @@ pub(crate) fn build_turn_metric_counts( // from input+output. total_tokens: usage.turn_total_tokens, cost_usd: usage.turn_cost_usd, - cache_read_tokens: None, + // Field-local: present when the cumulative counter was monotonic + // across this turn. Zero means no cache hits this turn (not absent). + cache_read_tokens: usage.turn_cache_read_tokens, + // buzz-agent does not emit a cache-write count on the wire today; + // leave None rather than deriving it from other fields. cache_write_tokens: None, }) } else { @@ -3457,7 +3646,13 @@ pub(crate) fn build_turn_metric_counts( // one. Never derived from input+output (NIP-AM MUST NOT). total_tokens: usage.cumulative_total_tokens, cost_usd: usage.cumulative_cost_usd, - cache_read_tokens: None, + // Session-cumulative cache-read tokens; None when the harness never + // reported this field (e.g. goose or older buzz-agent sessions). + // Passes through directly — do not wrap in Some() as the field already + // carries provenance (None vs Some(0) are distinct meanings). + cache_read_tokens: usage.cumulative_cache_read_tokens, + // buzz-agent does not emit a cache-write count on the wire today; + // leave None rather than deriving it from other fields. cache_write_tokens: None, }); (turn_counts, cumulative_counts) @@ -4204,6 +4399,572 @@ mod tests { assert!(parse_dm_response(json, 12).is_none()); } + #[test] + fn test_parse_nostr_thread_response_marks_query_window_truncated() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let agent_hex = agent.public_key().to_hex(); + let json = json!([ + { + "id": root_id, + "pubkey": "rootpub", + "content": "root", + "created_at": 1000 + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": agent_hex, + "content": "newest agent reply", + "created_at": 4000 + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "humanpub", + "content": "middle reply", + "created_at": 3000 + }, + { + "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "oldpub", + "content": "sentinel omitted reply", + "created_at": 2000 + } + ]); + + let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key()) + .expect("should parse"); + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert_eq!(messages.len(), 3); // root + 2 displayed replies + assert_eq!(total, 4); // root + displayed replies + sentinel + assert!(truncated); + assert_eq!(messages[0].content, "root"); + assert_eq!(messages[1].content, "middle reply"); + assert_eq!(messages[2].content, "newest agent reply"); + assert!(messages + .iter() + .all(|msg| msg.content != "sentinel omitted reply")); + } + _ => panic!("expected Thread context"), + } + } + + #[test] + fn test_parse_nostr_thread_response_not_truncated_below_limit() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let json = json!([ + { + "id": root_id, + "pubkey": "rootpub", + "content": "root", + "created_at": 1000 + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "replypub", + "content": "reply", + "created_at": 2000 + } + ]); + + let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key()) + .expect("should parse"); + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert_eq!(messages.len(), 2); + assert_eq!(total, 2); + assert!(!truncated); + } + _ => panic!("expected Thread context"), + } + } + + #[test] + fn test_parse_nostr_thread_response_keeps_agent_reply_outside_recent_window() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let agent_hex = agent.public_key().to_hex(); + let json = json!([ + { + "id": root_id, + "pubkey": "rootpub", + "content": "root", + "created_at": 1000 + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "humanpub", + "content": "newer human reply", + "created_at": 5000 + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "humanpub", + "content": "middle human reply", + "created_at": 4000 + }, + { + "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "humanpub", + "content": "oldest displayed reply without agent pin", + "created_at": 3000 + }, + { + "id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "pubkey": agent_hex, + "content": "agent reply outside recent window", + "created_at": 2000 + } + ]); + + let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key()) + .expect("should parse"); + match ctx { + ConversationContext::Thread { messages, .. } => { + assert_eq!(messages.len(), 3); // root + 2 displayed replies + assert_eq!(messages[0].content, "root"); + assert!(messages + .iter() + .any(|msg| msg.content == "agent reply outside recent window")); + assert!(messages + .iter() + .any(|msg| msg.content == "newer human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "middle human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "oldest displayed reply without agent pin")); + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_uses_exact_count_when_above_sentinel_minimum() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let agent_pubkey = agent.public_key(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent_pubkey, + move |filters| { + assert_thread_query_filters(&filters, channel_id, root_id, agent_pubkey, 3); + std::future::ready(Ok(json.clone())) + }, + move |filters| { + assert_thread_count_filter(&filters, channel_id, root_id); + std::future::ready(Ok(json!({ "count": 6 }))) + }, + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!(total, 7); // 6 replies + root + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_does_not_add_missing_root_to_exact_count() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Ok(json!({ "count": 6 }))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 2); + assert_eq!(total, 6); + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_clamps_count_below_sentinel_minimum() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Ok(json!({ "count": 1 }))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!(total, 4); // root + displayed replies + sentinel minimum + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_preserves_sentinel_minimum_when_count_fails() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!(total, 4); // count failure leaves parser's sentinel minimum intact + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_deduplicates_and_pins_agent_reply() { + let agent = Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newer human reply", + 5000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle human reply", + 4000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + &agent_hex, + "agent reply outside recent window", + 2000 + ), + // Same event as the separately fetched author-filtered result; the + // parser should deduplicate it before pinning. + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + &agent_hex, + "agent reply outside recent window", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Ok(json!({ "count": 3 }))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(total, 4); + assert_eq!(messages.len(), 3); + assert_eq!( + messages + .iter() + .filter(|msg| msg.content == "agent reply outside recent window") + .count(), + 1, + "separate agent-reply query must not duplicate the same event" + ); + assert!(messages + .iter() + .any(|msg| msg.content == "newer human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "middle human reply")); + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_uses_distinct_fetched_replies_as_minimum() { + let agent = Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest human reply", + 5000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle human reply", + 4000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel human reply", + 3000 + ), + thread_event( + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + &agent_hex, + "older distinct agent reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!( + total, 5, + "root plus all four distinct fetched replies prove the lower bound" + ); + assert!(messages + .iter() + .any(|msg| msg.content == "older distinct agent reply")); + assert!(messages + .iter() + .any(|msg| msg.content == "newest human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "middle human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "sentinel human reply")); + } + _ => panic!("expected Thread context"), + } + } + + fn assert_thread_query_filters( + filters: &[nostr::Filter], + channel_id: Uuid, + root_id: &str, + agent_pubkey: nostr::PublicKey, + reply_limit: u64, + ) { + assert_eq!( + filters.len(), + 3, + "root, recent replies, and agent reply filters" + ); + + let root = serde_json::to_value(&filters[0]).expect("serialize root filter"); + assert_eq!(root.get("ids"), Some(&json!([root_id]))); + assert!(root.get("limit").is_none()); + + let replies = serde_json::to_value(&filters[1]).expect("serialize replies filter"); + assert_eq!(replies.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(replies.get("#e"), Some(&json!([root_id]))); + assert_eq!(replies.get("#h"), Some(&json!([channel_id.to_string()]))); + assert_eq!(replies.get("limit"), Some(&json!(reply_limit))); + assert!(replies.get("authors").is_none()); + + let agent = serde_json::to_value(&filters[2]).expect("serialize agent filter"); + assert_eq!(agent.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(agent.get("#e"), Some(&json!([root_id]))); + assert_eq!(agent.get("#h"), Some(&json!([channel_id.to_string()]))); + assert_eq!(agent.get("authors"), Some(&json!([agent_pubkey.to_hex()]))); + assert_eq!(agent.get("limit"), Some(&json!(1))); + } + + fn assert_thread_count_filter(filters: &[nostr::Filter], channel_id: Uuid, root_id: &str) { + assert_eq!(filters.len(), 1, "count should query only matching replies"); + + let count = serde_json::to_value(&filters[0]).expect("serialize count filter"); + assert_eq!(count.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(count.get("#e"), Some(&json!([root_id]))); + assert_eq!(count.get("#h"), Some(&json!([channel_id.to_string()]))); + assert_eq!(count.get("limit"), Some(&json!(0))); + assert!(count.get("ids").is_none()); + assert!(count.get("authors").is_none()); + } + + fn thread_event(id: &str, pubkey: &str, content: &str, created_at: u64) -> serde_json::Value { + json!({ + "id": id, + "pubkey": pubkey, + "content": content, + "created_at": created_at + }) + } + #[test] fn test_json_to_context_message_integer_timestamp() { let obj = json!({ @@ -5271,10 +6032,12 @@ mod tests { turn_output_tokens: Some(50), turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 100, cumulative_output_tokens: 50, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // owner_pubkey = None → early return, no panic. @@ -5305,10 +6068,12 @@ mod tests { turn_output_tokens: Some(80), turn_total_tokens: None, turn_cost_usd: Some(0.001), + turn_cache_read_tokens: None, cumulative_input_tokens: 200, cumulative_output_tokens: 80, cumulative_total_tokens: None, cumulative_cost_usd: Some(0.001), + cumulative_cache_read_tokens: None, model: None, }; // Will try to publish and fail (no real relay) but must not panic. @@ -5340,10 +6105,12 @@ mod tests { turn_output_tokens: Some(20), turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 150, cumulative_output_tokens: 70, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // Must not panic; HTTP submit will fail (no real relay) — that's fine. @@ -5375,10 +6142,12 @@ mod tests { turn_output_tokens: None, turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 400, cumulative_output_tokens: 100, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic. @@ -5407,10 +6176,12 @@ mod tests { turn_output_tokens: Some(30), turn_total_tokens: Some(130), // genuine per-turn total turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 500, cumulative_output_tokens: 120, cumulative_total_tokens: Some(620), // genuine cumulative total cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; @@ -5454,10 +6225,12 @@ mod tests { turn_output_tokens: Some(60), turn_total_tokens: None, // provider did not supply a total turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 200, cumulative_output_tokens: 60, cumulative_total_tokens: None, // session has no total cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; @@ -5497,6 +6270,96 @@ mod tests { ); } + /// A payload with nonzero `accumulatedCachedInputTokens` on the second turn + /// must produce a kind:44200 payload where `cumulative.cacheReadTokens` is + /// nonzero and `turn.cacheReadTokens` reflects the per-turn delta. + /// This is the acceptance-criterion test: it proves the threading is live, + /// not hardcoded to None. + #[test] + fn test_build_turn_metric_counts_cache_read_tokens_thread_through() { + // Wire-parse a buzz-agent payload with cache, run it through the tracker, + // and verify the published TokenCounts carry the cache field. + let raw1 = serde_json::json!({ + "sessionId": "cache-sess", + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 15_091, + "accumulatedOutputTokens": 156, + "accumulatedCachedInputTokens": 5_033, + } + }); + let raw2 = serde_json::json!({ + "sessionId": "cache-sess", + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 28_500, + "accumulatedOutputTokens": 310, + "accumulatedCachedInputTokens": 11_000, + } + }); + + let mut tracker = crate::usage::UsageTracker::default(); + + // Turn 1 — establish baseline (delta unreliable, but cumulative still present). + tracker.begin_turn("cache-sess"); + if let crate::usage::GooseSessionUpdateVariant::UsageUpdate(p) = + serde_json::from_value::(raw1) + .unwrap() + .update + { + tracker.record("cache-sess", &p); + } + let t1 = tracker.take().expect("turn 1"); + + // Turn 1: cumulative must carry the cache count; turn delta is None (no baseline). + let (turn1, cum1) = crate::pool::build_turn_metric_counts(&t1); + // delta_reliable = false on first turn → no turn counts. + assert!(turn1.is_none(), "first turn: no reliable turn counts"); + let cum1 = cum1.expect("cumulative always present"); + assert_eq!( + cum1.cache_read_tokens, + Some(5_033), + "cumulative.cacheReadTokens must be 5033 after turn 1" + ); + + // Turn 2 — delta reliable. + tracker.begin_turn("cache-sess"); + if let crate::usage::GooseSessionUpdateVariant::UsageUpdate(p) = + serde_json::from_value::(raw2) + .unwrap() + .update + { + tracker.record("cache-sess", &p); + } + let t2 = tracker.take().expect("turn 2"); + + let (turn2, cum2) = crate::pool::build_turn_metric_counts(&t2); + + let turn2 = turn2.expect("reliable turn counts on turn 2"); + // Per-turn cache delta: 11_000 - 5_033 = 5_967. + assert_eq!( + turn2.cache_read_tokens, + Some(5_967), + "turn.cacheReadTokens must be the per-turn delta" + ); + // cache_write_tokens is always None — buzz-agent doesn't emit it. + assert!( + turn2.cache_write_tokens.is_none(), + "cache_write_tokens must be None — not emitted by buzz-agent" + ); + + let cum2 = cum2.expect("cumulative always present"); + assert_eq!( + cum2.cache_read_tokens, + Some(11_000), + "cumulative.cacheReadTokens must be 11_000 after turn 2" + ); + assert!( + cum2.cache_write_tokens.is_none(), + "cache_write_tokens must be None on cumulative too" + ); + } + fn make_prompt_context_no_owner() -> PromptContext { let agent_keys = nostr::Keys::generate(); make_prompt_context_impl(&agent_keys, None) diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index c8312cc61e..aea5cee077 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -405,6 +405,19 @@ impl RestClient { .map_err(|e| RelayError::Http(e.to_string())) } + /// Count events via the HTTP bridge: `POST /count` with NIP-98 auth. + /// + /// Accepts a slice of `nostr::Filter` (serialized as JSON array). + /// Returns the bridge response as a `serde_json::Value` (usually `{ "count": n }`). + pub async fn count(&self, filters: &[nostr::Filter]) -> Result { + let body_bytes = serde_json::to_vec(filters) + .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?; + let resp = self.bridge_post("/count", &body_bytes).await?; + resp.json() + .await + .map_err(|e| RelayError::Http(e.to_string())) + } + /// Submit a signed event via the HTTP bridge: `POST /events` with NIP-98 auth. /// /// The event must already be signed. Returns the relay response JSON. diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index 1629eee935..56b772d12c 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -85,12 +85,16 @@ pub(crate) struct UsageUpdatePayload { pub context_limit: u64, pub accumulated_input_tokens: u64, pub accumulated_output_tokens: u64, - /// The cache-served subset of `accumulated_input_tokens`. Optional — goose - /// does not send it, and buzz-agent only reports a non-zero value when the - /// provider returned a cache split, so `0` legitimately means either "no - /// cache hits" or "provider reported none". - #[serde(default)] - pub accumulated_cached_input_tokens: u64, + /// The cache-served subset of `accumulated_input_tokens`. + /// + /// `None` when the harness did not include the field (e.g. goose, which + /// never emits it). `Some(0)` when the harness explicitly reported zero + /// cache hits. The distinction matters: `None` means "we don't know", + /// while `Some(0)` means "provider confirmed no cache was used". + /// + /// Do NOT use `#[serde(default)]` here — that would collapse the absent + /// case into `Some(0)` and destroy provenance in the append-only archive. + pub accumulated_cached_input_tokens: Option, pub accumulated_cost: Option, /// Session-cumulative genuine provider total tokens. Optional — only /// emitted by buzz-agent when every turn in the session so far supplied a @@ -125,6 +129,12 @@ struct SessionState { /// `None` when the session has never emitted a provider total (Unseen) or /// when any prior turn lacked one (poisoned). last_total: Option, + /// Cumulative cache-read input tokens at the end of the LAST PUBLISHED turn. + /// `None` when the harness has never reported this field (e.g. goose). + /// `Some(n)` when at least one payload included the field. Field-local: + /// a decrease in this counter taints only the cache-read delta, not + /// `delta_reliable` or the input/output deltas. + last_cached_input: Option, } /// Per-turn usage record exposed to `TurnCompletionGuard` for NIP-AM publishing. @@ -151,6 +161,12 @@ pub struct TurnUsage { /// Per-turn cost delta (`current − previous`); `None` when unreliable or /// either snapshot is missing. pub turn_cost_usd: Option, + /// Per-turn cache-read token delta (`current − previous`); `None` when no + /// baseline exists, either snapshot is `None` (harness did not report it), + /// or the cumulative counter decreased (field-local taint). Field-local: + /// a decrease here never flips `delta_reliable` or invalidates the + /// input/output deltas. + pub turn_cache_read_tokens: Option, /// Session-cumulative input tokens as reported by goose at end of turn. pub cumulative_input_tokens: u64, /// Session-cumulative output tokens as reported by goose at end of turn. @@ -160,6 +176,11 @@ pub struct TurnUsage { pub cumulative_total_tokens: Option, /// Session-cumulative estimated cost in USD; `None` if goose did not report it. pub cumulative_cost_usd: Option, + /// Session-cumulative cache-read input tokens as reported by buzz-agent. + /// `None` when the harness has never reported this field (e.g. goose or + /// any harness that omits `accumulatedCachedInputTokens`). + /// `Some(0)` when the harness reported zero cache hits. + pub cumulative_cache_read_tokens: 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, @@ -239,6 +260,7 @@ impl UsageTracker { let current_output = payload.accumulated_output_tokens; let current_cost = payload.accumulated_cost; let current_total = payload.accumulated_total_tokens; + let current_cached_input = payload.accumulated_cached_input_tokens; // Determine whether this session is currently in-flight so we know // whether to set `pending`. We compute the delta regardless so that @@ -294,6 +316,21 @@ impl UsageTracker { None => None, // no baseline yet }; + // Cache-read token delta: field-local — never affects `delta_reliable` + // or the input/output deltas. Null when: no baseline exists, either + // snapshot is None (harness did not report the field), or the cumulative + // counter decreased (harness restart, overflow). + // Some(0) is a valid result when both snapshots are Some(0) — it means + // the harness confirmed zero cache hits this turn, not that data is absent. + let turn_cache_read = match self.sessions.get(session_id) { + Some(prev) => match (current_cached_input, prev.last_cached_input) { + (Some(cur), Some(p)) if cur >= p => Some(cur - p), + (Some(_), Some(_)) => None, // decrease → field-local taint + _ => None, // either snapshot absent → no delta + }, + None => None, // no baseline yet + }; + if is_in_flight { // In-flight-match: update pending with the latest cumulative values. // Baseline is NOT advanced here — it advances only on take(). @@ -305,10 +342,12 @@ impl UsageTracker { turn_output_tokens: turn_output, turn_total_tokens: turn_total, turn_cost_usd: turn_cost, + turn_cache_read_tokens: turn_cache_read, cumulative_input_tokens: current_input, cumulative_output_tokens: current_output, cumulative_total_tokens: current_total, cumulative_cost_usd: current_cost, + cumulative_cache_read_tokens: current_cached_input, model: payload.model.clone(), }); } else if self.in_flight_session.is_none() { @@ -327,6 +366,7 @@ impl UsageTracker { last_output: current_output, last_cost: current_cost, last_total: current_total, + last_cached_input: current_cached_input, }, ); } @@ -355,6 +395,7 @@ impl UsageTracker { last_output: record.cumulative_output_tokens, last_cost: record.cumulative_cost_usd, last_total: record.cumulative_total_tokens, + last_cached_input: record.cumulative_cache_read_tokens, }, ); Some(record) @@ -366,9 +407,9 @@ mod tests { use super::*; /// The camelCase key buzz-agent actually puts on the wire must land on the - /// field. A rename mismatch here would deserialize to the serde default of - /// 0, and every trial would price as if nothing had ever been cached — the - /// exact silent failure this field was added to remove. + /// field. A rename mismatch here would deserialize to None, and every trial + /// would be treated as "not reported" — the exact silent failure this field + /// was added to remove. #[test] fn cached_input_tokens_deserialize_from_the_wire_key() { let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ @@ -379,13 +420,14 @@ mod tests { "accumulatedCachedInputTokens": 5_033, })) .expect("payload must deserialize"); - assert_eq!(p.accumulated_cached_input_tokens, 5_033); - assert!(p.accumulated_cached_input_tokens <= p.accumulated_input_tokens); + assert_eq!(p.accumulated_cached_input_tokens, Some(5_033)); + assert!(p.accumulated_cached_input_tokens.unwrap() <= p.accumulated_input_tokens); } - /// goose does not send the field; its payloads must still deserialize. + /// goose does not send the field; its payloads must deserialize with None — + /// not zero — so that "not reported" is preserved distinct from "reported zero". #[test] - fn a_payload_without_the_cache_field_defaults_to_zero() { + fn a_payload_without_the_cache_field_deserializes_as_none() { let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ "used": 500, "contextLimit": 200_000, @@ -393,7 +435,28 @@ mod tests { "accumulatedOutputTokens": 100, })) .expect("payload must deserialize without the cache field"); - assert_eq!(p.accumulated_cached_input_tokens, 0); + assert!( + p.accumulated_cached_input_tokens.is_none(), + "absent field must be None, not Some(0)" + ); + } + + /// A harness that explicitly reports zero cache hits must produce Some(0), + /// not None — so downstream analytics can distinguish "confirmed zero" from + /// "not reported". + #[test] + fn a_payload_with_explicit_zero_cache_field_deserializes_as_some_zero() { + let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ + "accumulatedInputTokens": 400, + "accumulatedOutputTokens": 100, + "accumulatedCachedInputTokens": 0, + })) + .expect("payload must deserialize with zero cache field"); + assert_eq!( + p.accumulated_cached_input_tokens, + Some(0), + "explicit zero must be Some(0), not None" + ); } fn payload(input: u64, output: u64, cost: Option) -> UsageUpdatePayload { @@ -402,7 +465,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, @@ -415,7 +478,7 @@ mod tests { context_limit: 0, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, @@ -913,7 +976,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: model.map(str::to_string), @@ -977,7 +1040,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: None, accumulated_total_tokens: total, model: None, @@ -1132,4 +1195,320 @@ mod tests { ); assert_eq!(usage.cumulative_total_tokens, Some(250)); } + + // ── cache-read token threading ────────────────────────────────────────── + + fn payload_with_cache( + input: u64, + output: u64, + cached_input: Option, + ) -> UsageUpdatePayload { + UsageUpdatePayload { + used: input + output, + context_limit: 200_000, + accumulated_input_tokens: input, + accumulated_output_tokens: output, + accumulated_cached_input_tokens: cached_input, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + } + } + + #[test] + fn cache_read_first_turn_produces_none_turn_delta_and_passes_cumulative_through() { + // First turn has no baseline → turn cache delta must be None, but + // cumulative_cache_read_tokens must carry the reported value through. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c1"); + tracker.record("sess-c1", &payload_with_cache(1000, 200, Some(500))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "first turn: no baseline → cache delta must be None" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(500), + "cumulative cache read passes through on first turn" + ); + assert!(!usage.delta_reliable, "first turn is unreliable"); + } + + #[test] + fn cache_read_second_turn_delta_computed_correctly() { + // Second turn: cumulative cached 500 → 1200, delta = 700. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c2"); + tracker.record("sess-c2", &payload_with_cache(1000, 200, Some(500))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c2"); + tracker.record("sess-c2", &payload_with_cache(2000, 350, Some(1200))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!( + usage.turn_cache_read_tokens, + Some(700), + "cache delta = 1200 - 500 = 700" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(1200), + "cumulative cache passes through" + ); + } + + #[test] + fn cache_read_decrease_nulls_turn_cache_but_leaves_delta_reliable() { + // Cache counter decrease → cache delta None (field-local taint), but + // delta_reliable and input/output deltas are NOT affected. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c3"); + tracker.record("sess-c3", &payload_with_cache(1000, 200, Some(800))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c3"); + // Cache counter decreased: 800 → 50. + tracker.record("sess-c3", &payload_with_cache(1500, 300, Some(50))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.delta_reliable, + "cache decrease must NOT flip delta_reliable — field-local" + ); + assert_eq!( + usage.turn_input_tokens, + Some(500), + "input/output delta unaffected by cache decrease" + ); + assert_eq!(usage.turn_output_tokens, Some(100)); + assert!( + usage.turn_cache_read_tokens.is_none(), + "cache counter decrease → turn_cache_read_tokens None (field-local taint)" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(50), + "cumulative still passes through from payload even on decrease" + ); + } + + #[test] + fn cache_read_explicit_zero_payload_after_explicit_zero_baseline_produces_some_zero_delta() { + // When both baseline and current are Some(0), turn_cache_read_tokens must + // be Some(0) — confirmed zero, not absent. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c4"); + tracker.record("sess-c4", &payload_with_cache(1000, 200, Some(0))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c4"); + tracker.record("sess-c4", &payload_with_cache(1500, 300, Some(0))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!( + usage.turn_cache_read_tokens, + Some(0), + "explicit zero on both sides → Some(0), not None" + ); + assert_eq!(usage.cumulative_cache_read_tokens, Some(0)); + } + + #[test] + fn cache_read_threads_through_setup_notification_baseline() { + // A setup notification (before begin_turn) with a nonzero cache count + // must update the committed baseline so the first real turn gets a + // correct delta from that starting point. + let mut tracker = UsageTracker::default(); + + // Setup notification: cumulative cache = 300. + tracker.record("sess-c5", &payload_with_cache(1000, 200, Some(300))); + + tracker.begin_turn("sess-c5"); + tracker.record("sess-c5", &payload_with_cache(1500, 350, Some(700))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable, "baseline from setup: reliable"); + assert_eq!( + usage.turn_cache_read_tokens, + Some(400), + "cache delta from setup baseline: 700 - 300 = 400" + ); + assert_eq!(usage.cumulative_cache_read_tokens, Some(700)); + } + + #[test] + fn cache_read_omitted_field_produces_none_cumulative_and_no_turn_delta() { + // A harness that omits accumulatedCachedInputTokens (e.g. goose) must + // produce None cumulative_cache_read_tokens — not Some(0) — and the + // turn delta must also be None even on the second turn. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c6"); + // payload() uses None for accumulated_cached_input_tokens. + tracker.record("sess-c6", &payload(1000, 200, None)); + let t1 = tracker.take().expect("turn 1"); + + assert!( + t1.cumulative_cache_read_tokens.is_none(), + "goose-shaped payload: cumulative must be None, not Some(0)" + ); + assert!( + t1.turn_cache_read_tokens.is_none(), + "first turn always has no turn delta" + ); + + tracker.begin_turn("sess-c6"); + tracker.record("sess-c6", &payload(1500, 300, None)); + let t2 = tracker.take().expect("turn 2"); + + assert!( + t2.cumulative_cache_read_tokens.is_none(), + "continued goose session: cumulative must remain None" + ); + assert!( + t2.turn_cache_read_tokens.is_none(), + "absent field on both sides → no turn delta invented" + ); + assert!( + t2.delta_reliable, + "input/output reliability unaffected by absent cache field" + ); + } + + #[test] + fn cache_read_baseline_absent_then_present_produces_no_delta() { + // If the first turn omits the cache field (baseline stored as None) and + // the second turn reports a value, no delta can be computed — we have no + // baseline to subtract from. The cumulative value should still pass through. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c7"); + tracker.record("sess-c7", &payload(1000, 200, None)); // no cache field + let _ = tracker.take(); + + tracker.begin_turn("sess-c7"); + tracker.record("sess-c7", &payload_with_cache(1500, 300, Some(400))); + let usage = tracker.take().expect("turn 2"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "absent baseline → no turn delta even when current has a value" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(400), + "cumulative from current payload passes through" + ); + assert!(usage.delta_reliable, "input/output reliability unaffected"); + } + + #[test] + fn cache_read_baseline_present_then_absent_produces_no_delta() { + // If the first turn reports the cache field but the second omits it + // (harness switched), no delta should be produced and cumulative is None. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c8"); + tracker.record("sess-c8", &payload_with_cache(1000, 200, Some(300))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c8"); + tracker.record("sess-c8", &payload(1500, 300, None)); // no cache field + let usage = tracker.take().expect("turn 2"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "absent current → no turn delta" + ); + assert!( + usage.cumulative_cache_read_tokens.is_none(), + "absent field: cumulative must be None" + ); + assert!(usage.delta_reliable, "input/output reliability unaffected"); + } + + #[test] + fn pool_omitted_cache_field_publishes_no_cache_read_tokens_in_kind44200() { + // End-to-end: a buzz-agent or goose payload that omits the cache field + // must NOT publish cacheReadTokens in the kind:44200 event — neither + // in turn nor cumulative counts. + // + // This is the core acceptance test for Thufir's finding: the old code + // would publish cacheReadTokens: 0 for every harness regardless of + // whether the field was reported. + use crate::pool::build_turn_metric_counts; + + let usage = TurnUsage { + session_id: "sess-pool-none".into(), + turn_seq: 2, + delta_reliable: true, + turn_input_tokens: Some(400), + turn_output_tokens: Some(100), + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: None, + cumulative_input_tokens: 700, + cumulative_output_tokens: 200, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, // harness did not report the field + model: None, + }; + + let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); + + let turn = turn_counts.expect("turn counts must be present (delta reliable)"); + assert!( + turn.cache_read_tokens.is_none(), + "omitted cache field: turn cacheReadTokens must be absent from kind:44200" + ); + + let cumulative = cumulative_counts.expect("cumulative counts always present"); + assert!( + cumulative.cache_read_tokens.is_none(), + "omitted cache field: cumulative cacheReadTokens must be absent from kind:44200" + ); + } + + #[test] + fn pool_reported_cache_field_publishes_nonzero_cache_read_tokens_in_kind44200() { + // End-to-end: a buzz-agent payload with a nonzero cache count must + // publish cacheReadTokens in both turn and cumulative counts. + use crate::pool::build_turn_metric_counts; + + let usage = TurnUsage { + session_id: "sess-pool-some".into(), + turn_seq: 2, + delta_reliable: true, + turn_input_tokens: Some(400), + turn_output_tokens: Some(100), + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: Some(300), + cumulative_input_tokens: 700, + cumulative_output_tokens: 200, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: Some(600), + model: None, + }; + + let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); + + let turn = turn_counts.expect("turn counts present"); + assert_eq!( + turn.cache_read_tokens, + Some(300), + "nonzero turn cache: must appear in kind:44200 turn counts" + ); + + let cumulative = cumulative_counts.expect("cumulative counts present"); + assert_eq!( + cumulative.cache_read_tokens, + Some(600), + "nonzero cumulative cache: must appear in kind:44200 cumulative counts" + ); + } } diff --git a/crates/buzz-acp/tests/config_env.rs b/crates/buzz-acp/tests/config_env.rs new file mode 100644 index 0000000000..177ac3fea5 --- /dev/null +++ b/crates/buzz-acp/tests/config_env.rs @@ -0,0 +1,21 @@ +use std::process::Command; + +#[test] +fn empty_mcp_config_environment_value_is_treated_as_unset() { + let output = Command::new(env!("CARGO_BIN_EXE_buzz-acp")) + .env("BUZZ_ACP_MCP_CONFIG", "") + .args(["--private-key", "not-a-valid-nostr-key"]) + .output() + .expect("run buzz-acp"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success()); + assert!( + stderr.contains("configuration error: failed to parse nostr keys"), + "empty optional MCP config should reach normal configuration validation: {stderr}" + ); + assert!( + !stderr.contains("a value is required for '--mcp-config"), + "empty optional MCP config must not fail Clap parsing: {stderr}" + ); +} diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index f138e4a4f1..5d942777d5 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -163,6 +163,67 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. | | `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. | | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. | +| `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. | + + +## Reply Guard + +Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop +sets `BUZZ_AGENT_REQUIRE_REPLY=1` automatically. With it enabled, a turn that is +about to end without any recognized attempt to post to Buzz gets a reminder that +its assistant text is invisible to humans, and is rerolled. + +This exists because a Buzz agent's reasoning and tool output are not shown to +anyone. A turn that does real work and never posts is a silent failure — the +requester waits on a result that was produced and thrown away. + +Mesh agents get it by default because they run on small local models, which are +the ones most likely to do the work and then end the turn without publishing it. +Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts a +mesh agent back out; the default never overrides an explicit value. + +**Advisory, never a trap.** At most two reminders, then the turn ends whether or +not anything was published. The guard catches accidental omission; it does not +compel speech. The reminder text explicitly licenses silence, because the +built-in system prompt says publishing is optional and silence is often the +correct outcome. + +**Recognition contract.** A turn counts as having replied when it issues a call +that: + +- resolves to a registered, non-hook tool (a hallucinated tool name is rejected + at preflight and never runs, so it must not disarm the guard), +- whose qualified name ends in `__shell` — i.e. the bare tool name is exactly + `shell`, which is `buzz-dev-mcp`'s shell tool and any other server's, and +- whose `command` argument contains `messages send` or `reactions add`. + +`messages send` also covers `messages send-diff`. Reactions count because the +built-in prompt directs agents to react rather than post a bare +acknowledgement, so nagging an agent that reacted would punish documented +behavior. + +Detection is checked **after** the per-turn tool-call cap +(`MAX_TOOL_CALLS_PER_TURN`) is applied: a publish-shaped call that was discarded +never ran. + +**It recognizes an attempt, not a successful publish.** Only the command text is +inspected, never the exit status. A send that fails still satisfies the guard — +which is fine, since a failed send already returns a non-zero exit and error +JSON to the model, louder feedback than a reminder. + +**Known limits**, both deliberate. A command assembled at runtime (`$CMD`) or +buried in a wrapper script is missed, so that turn is reminded despite having +posted. Text that merely quotes a send (`echo "buzz messages send"`) matches, so +that turn is not reminded. Missing a real post is the expensive direction, and +substring matching is the forgiving one there. Neither edge is pinned by a test; +the matcher is free to improve. + +**Budget.** Reminders ride the existing `_Stop` gate and share +`BUZZ_AGENT_STOP_MAX_REJECTIONS` — the outer cap on every end-turn objection. +At the default 3 both reminders fit; at 1 only one does; at 0 the guard is off +along with the hooks. A round carrying both a `_Stop` hook objection and a +reminder costs one rejection and delivers both texts. This is not a new +lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md). ## Providers diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 48d4ea3b02..8e14fee195 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -21,6 +21,80 @@ use crate::wire::{self, WireSender}; const ERROR_REFLECTION_SUFFIX: &str = "\n\n[Reflect] Before retrying, identify the cause and change your approach."; +/// Maximum reply reminders emitted per prompt when `require_reply` is on. +/// +/// After this many, the turn is allowed to end whether or not anything was +/// published: the guard exists to catch accidental omission, not to compel +/// speech. The shared `stop_max_rejections` budget can cut this lower — see +/// [`Config::require_reply`](crate::config::Config::require_reply). +const MAX_REPLY_NAGS: u32 = 2; + +/// Server label on the synthetic reply-guard objection. +/// +/// Not a real MCP server. It rides the same tool-result path as `_Stop` hook +/// output, so the model sees `{hook, server, text}` attribution naming the +/// in-process guard rather than an MCP server that could be impersonated. +const REPLY_GUARD_SERVER: &str = "buzz-agent"; + +/// Reminder text emitted when a turn is about to end with nothing published. +/// +/// Explicitly licenses silence. The base prompt tells agents that publishing is +/// optional and "silence is usually correct"; a reminder that argued otherwise +/// would fight that instruction and make agents chattier. +const REPLY_GUARD_NAG: &str = "You are about to end this turn without calling `buzz messages send`. \ +Your assistant text and reasoning are never shown to anyone — if you did work, found an answer, \ +or hit a blocker that someone is waiting on, it exists only if you publish it. \ +If you already posted, or if silence is genuinely correct for this turn, ignore this and end your turn."; + +/// Whether `call` is a recognized attempt to publish a reply to Buzz. +/// +/// Recognizes an *attempt*, not a successful publish: the command text is +/// inspected, never the exit status. That is deliberate — a send that fails +/// already returns a non-zero exit and error JSON to the model, which is louder +/// feedback than the reminder this gates. +/// +/// `has` + `!is_hook` are the same checks the dispatcher uses to accept a call +/// (see `execute_calls`), so a hallucinated `fake__shell` — rejected at preflight +/// and never executed — cannot disarm the guard. They must stay *before* +/// [`is_reply_shaped`]: together with them, and only with them, the `__shell` +/// suffix is exactly equivalent to "the bare tool name is `shell`". +fn is_buzz_reply_call(call: &ToolCall, mcp: &McpRegistry) -> bool { + mcp.has(&call.name) && !mcp.is_hook(&call.name) && is_reply_shaped(&call.name, &call.arguments) +} + +/// Whether a tool name and arguments have the shape of a Buzz publish command. +/// +/// Split from [`is_buzz_reply_call`] only so the matcher is testable without a +/// live [`McpRegistry`]; callers must apply the registry checks first. +/// +/// On the name: `ends_with("__shell")` is exact rather than approximate *given* +/// those checks. Registration rejects `__` in both server names and bare tool +/// names, and qualified names are `{server}__{bare}`, so a trailing `__shell` can +/// only straddle the separator if the bare name starts with `_` — which `is_hook` +/// already excludes. Dropping the separator would not be exact: `powershell` and +/// `noshell` both end in `shell`. +/// +/// On the command: a deliberately coarse substring test, scoped to the structured +/// `command` field so unrelated metadata — a `description` that quotes a send — +/// cannot suppress the guard, and a non-string `command` is rejected rather than +/// coerced. Known limits, both accepted: a command assembled at runtime (`$CMD`) +/// or hidden in a wrapper script is missed, and text that merely quotes a send +/// (`echo "buzz messages send"`) matches. Missing a real post is the expensive +/// direction, and substring matching is the more forgiving one there. +fn is_reply_shaped(name: &str, arguments: &serde_json::Value) -> bool { + name.ends_with("__shell") + && arguments + .get("command") + .and_then(|v| v.as_str()) + .is_some_and(|cmd| { + // `messages send` also covers `messages send-diff`. `reactions + // add` counts because the base prompt directs agents to react + // rather than post a bare acknowledgement, so nagging an agent + // that reacted would punish documented-correct behavior. + cmd.contains("messages send") || cmd.contains("reactions add") + }) +} + pub struct RunCtx<'a> { pub cfg: &'a Config, /// Effective model for this session. Usually equals `cfg.model`; overridden @@ -102,6 +176,14 @@ impl RunCtx<'_> { // session) so a stubborn exchange can't permanently disable the stop // guard for a long-lived session; `max_rounds` still caps the loop. let mut stop_rejections = 0u32; + // Reply-guard state for this prompt. `prompt()` *is* the turn, so + // locals here are per-turn by construction — same shape as + // `stop_rejections` above. + // + // Named for what it proves: a *recognized attempt* to publish, not a + // successful publish. See `is_buzz_reply_call`. + let mut buzz_reply_call_seen = false; + let mut reply_nags = 0u32; loop { if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds { return Ok(StopReason::MaxTurnRequests); @@ -264,7 +346,7 @@ impl RunCtx<'_> { if stop_rejections >= self.cfg.stop_max_rejections { return Ok(stop); } - let objections = self + let mut objections = self .mcp .call_hooks( "_Stop", @@ -273,6 +355,17 @@ impl RunCtx<'_> { &self.cfg.hook_servers, ) .await; + // Reply guard shares this gate and this budget, so a round + // carrying both a hook objection and a reply reminder costs + // one rejection and delivers both texts. + if self.cfg.require_reply + && !buzz_reply_call_seen + && reply_nags < MAX_REPLY_NAGS + { + reply_nags += 1; + objections + .push((REPLY_GUARD_SERVER.to_string(), REPLY_GUARD_NAG.to_string())); + } if !objections.is_empty() { stop_rejections = stop_rejections.saturating_add(1); push_hook_outputs_as_tool_results(self.history, "_Stop", &objections); @@ -290,6 +383,11 @@ impl RunCtx<'_> { ); calls.truncate(MAX_TOOL_CALLS_PER_TURN); } + // Deliberately after truncation: a publish-shaped call that was + // discarded never runs, so it must not suppress the reminder. + if self.cfg.require_reply && !buzz_reply_call_seen { + buzz_reply_call_seen = calls.iter().any(|c| is_buzz_reply_call(c, self.mcp)); + } self.history.push(HistoryItem::Assistant { text: response.text, tool_calls: calls.clone(), @@ -799,6 +897,88 @@ mod tests { use super::*; use serde_json::json; + /// The shapes the guard must recognize as a publish attempt. Callers apply + /// the registry checks first; these cover the name suffix and command text. + #[test] + fn reply_shape_matches_documented_send_forms() { + for cmd in [ + "buzz messages send --channel X --content Y", + "buzz --relay wss://r messages send --channel X --content Y", + "/abs/path/buzz messages send", + "printf 'hi' | buzz messages send --content -", + "buzz messages send-diff --diff -", + "buzz reactions add --event E --emoji +", + // Assembled through another shell: rev 3's tokenizer missed this. + r#"sh -c "buzz messages send --channel X""#, + ] { + assert!( + is_reply_shaped("dev__shell", &json!({ "command": cmd })), + "expected {cmd:?} to count as a publish attempt" + ); + } + } + + /// Commands that do real work but do not reply in the originating + /// conversation must still be nagged. + #[test] + fn reply_shape_rejects_non_reply_commands() { + for cmd in [ + "buzz messages get --channel X", + "buzz channels list", + "buzz reactions remove --event E", + "buzz pr open --title T", + "buzz social publish --content hi", + "buzz notes set --name n", + "cargo test -p buzz-agent", + ] { + assert!( + !is_reply_shaped("dev__shell", &json!({ "command": cmd })), + "expected {cmd:?} not to count as a publish attempt" + ); + } + } + + /// The `__` separator is load-bearing: `ends_with("shell")` alone would + /// accept any registered tool whose name merely ends in those letters, and + /// `has()` proves registration, not the bare name. + #[test] + fn reply_shape_requires_the_qname_separator() { + let args = json!({ "command": "buzz messages send --channel X" }); + for name in [ + "dev__powershell", + "dev__noshell", + "shell", + "dev__send_message", + ] { + assert!( + !is_reply_shaped(name, &args), + "{name} must not satisfy the shell-tool check" + ); + } + assert!(is_reply_shaped("dev__shell", &args)); + assert!(is_reply_shaped("buzz-dev-mcp__shell", &args)); + } + + /// Only the field that carries the executable command counts. Searching + /// serialized arguments instead would let arbitrary metadata disarm the + /// guard, turning a description into an attempted send. + #[test] + fn reply_shape_reads_only_the_command_field() { + assert!(!is_reply_shaped( + "dev__shell", + &json!({ "description": "buzz messages send --channel X" }) + )); + assert!(!is_reply_shaped( + "dev__shell", + &json!({ "workdir": "buzz messages send" }) + )); + // Malformed `command` is rejected, not coerced — and must not panic. + assert!(!is_reply_shaped("dev__shell", &json!({ "command": 42 }))); + assert!(!is_reply_shaped("dev__shell", &json!({ "command": null }))); + assert!(!is_reply_shaped("dev__shell", &json!({}))); + assert!(!is_reply_shaped("dev__shell", &json!("not an object"))); + } + /// A9 regression: `reasoning_details` contributes real bytes to /// `estimated_bytes` (see `types.rs::HistoryItem::size_with`), so a /// history item carrying a large opaque reasoning array must actually diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index a0e64f1a9d..afbda5379d 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -720,6 +720,16 @@ pub struct Config { /// Maximum `_Stop` rejections per prompt. Default 3. Set to 0 to /// disable `_Stop` hooks entirely (agent always honors end_turn). pub stop_max_rejections: u32, + /// Remind the model to publish when a turn is about to end without any + /// recognized attempt to post to Buzz. Default off; opt in per agent with + /// `BUZZ_AGENT_REQUIRE_REPLY=1`. + /// + /// Advisory only: at most `MAX_REPLY_NAGS` reminders (see `agent.rs`), + /// then the turn ends regardless. Bounded by the same + /// `stop_max_rejections` budget as `_Stop` hooks, which is the outer cap on + /// all end-turn objections — at the default 3 both reminders fit; at 1 only + /// one does; at 0 the guard is off with the hooks. + pub require_reply: bool, /// Hook server allowlist. See [`HookServers`] for variant semantics. /// Default (env unset/empty) is `None` — hooks are off unless the /// operator explicitly opts in. @@ -851,6 +861,7 @@ impl Config { max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?, hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?), stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?, + require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0, hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?, @@ -893,6 +904,7 @@ impl Config { max_parallel_tools: 1, hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, + require_reply: false, hook_servers: HookServers::None, hints_enabled: false, thinking_effort: None, diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index f595a165e5..73c7e1faf2 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2355,6 +2355,7 @@ mod tests { max_parallel_tools: 1, hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, + require_reply: false, hook_servers: HookServers::None, api_key: "key".into(), model: "model".into(), diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 0bbd1d3478..5b660da48c 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -33,6 +33,11 @@ //! — expose a `_PostCompact` hook tool //! FAKE_MCP_POSTCOMPACT_TEXT=text //! — `_PostCompact` returns this (default: "") +//! FAKE_MCP_SHELL_TOOL=1 — expose a tool whose bare name is `shell` +//! (registered as `__shell`), taking a +//! `command` string. Lets a test drive the +//! reply guard's recognition of a real, +//! registered shell tool. use std::io::{BufRead, Write}; @@ -76,6 +81,7 @@ fn make_tools( desc: &str, include_stop_hook: bool, include_post_compact_hook: bool, + include_shell_tool: bool, ) -> Vec { let mut tools: Vec = (0..count) .map(|i| { @@ -100,6 +106,17 @@ fn make_tools( "inputSchema": { "type": "object", "properties": {} }, })); } + if include_shell_tool { + tools.push(json!({ + "name": "shell", + "description": "run a shell command", + "inputSchema": { + "type": "object", + "properties": { "command": { "type": "string" } }, + "required": ["command"], + }, + })); + } tools } @@ -136,6 +153,7 @@ fn main() { let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX); let mut stop_calls_seen: usize = 0; let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK"); + let shell_tool = env_flag("FAKE_MCP_SHELL_TOOL"); let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default(); // Use a channel-based stdin reader so notifications (which carry no id) @@ -206,7 +224,13 @@ fn main() { write_response( id, json!({ - "tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook) + "tools": make_tools( + tool_count, + &desc, + stop_hook, + post_compact_hook, + shell_tool, + ) }), ); } diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index 2e0b579c84..abb4f7b311 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -1819,3 +1819,465 @@ async fn cancel_sends_notifications_cancelled_to_any_mcp_server() { let _ = std::fs::remove_file(&call_received_marker); h.shutdown().await; } + +// --------------------------------------------------------------------------- +// Reply guard (`BUZZ_AGENT_REQUIRE_REPLY`) +// +// The guard reminds the model to publish when a turn is about to end without +// any recognized attempt to post to Buzz. It rides the existing `_Stop` gate +// and shares its rejection budget, so most of these tests count LLM calls: +// each reminder costs exactly one extra round. +// --------------------------------------------------------------------------- + +/// Number of reply-guard reminders present in one captured LLM request. +/// +/// A reminder is a tool-role message whose JSON body is attributed to the +/// in-process guard (`server: "buzz-agent"`) at the `_Stop` hook point — the +/// same lower-trust shape as real hook output. +fn reply_nag_count(request: &Value) -> usize { + request["messages"] + .as_array() + .map(|msgs| { + msgs.iter() + .filter(|m| { + m["role"] == "tool" + && serde_json::from_str::(m["content"].as_str().unwrap_or("")) + .map(|p| p["hook"] == "_Stop" && p["server"] == "buzz-agent") + .unwrap_or(false) + }) + .count() + }) + .unwrap_or(0) +} + +/// A publish-shaped call to a real registered shell tool. +fn openai_shell_send(id: &str) -> Value { + openai_tool_call( + id, + "fake__shell", + json!({ "command": "buzz messages send --channel c --content hi" }), + ) +} + +/// Run one prompt to completion, answering any permission requests, and +/// return the final response. +async fn prompt_to_completion(h: &mut Harness, sid: &str) -> Value { + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + loop { + let v = h.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + let id = v["id"].clone(); + h.write(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, + })) + .await; + continue; + } + if v["id"] == json!(p) { + return v; + } + } +} + +/// Default off: a silent turn ends on the first end_turn with no extra round. +/// This is the invariant that keeps the feature free for everyone who hasn't +/// opted in. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_off_by_default() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "guard must be inert when unset, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// `BUZZ_AGENT_REQUIRE_REPLY=0` is off too — the toggle is numeric, so a +/// literal `0` must not read as "set, therefore on". +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_explicit_zero_is_off() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "0")]).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "REQUIRE_REPLY=0 must behave as off, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// Opted in and silent: exactly two reminders, then the turn is allowed to +/// end. The guard is advisory — it must never trap a turn. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_nags_twice_then_lets_the_turn_end() { + // Budget defaults to 3, so the cap that stops the loop here is + // MAX_REPLY_NAGS = 2, not the rejection budget. + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("silent-3"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "expected 2 reminders then end_turn (3 LLM calls), got {}", + captured.len() + ); + assert_eq!( + reply_nag_count(&captured[0]), + 0, + "reminder before any end_turn" + ); + assert_eq!(reply_nag_count(&captured[1]), 1); + assert_eq!(reply_nag_count(&captured[2]), 2); + + // The reminder must name the command it wants and license silence, so it + // cannot fight the base prompt's "silence is usually correct". + let msgs = captured[2]["messages"].as_array().unwrap(); + let nag = msgs + .iter() + .filter_map(|m| serde_json::from_str::(m["content"].as_str().unwrap_or("")).ok()) + .find(|p| p["server"] == "buzz-agent") + .expect("reminder body"); + let text = nag["text"].as_str().unwrap_or(""); + assert!( + text.contains("buzz messages send"), + "reminder should name the command: {text}" + ); + assert!( + text.contains("silence is genuinely correct"), + "reminder must license silence: {text}" + ); + h.shutdown().await; +} + +/// A real publish attempt through a registered shell tool satisfies the guard: +/// no reminder, no extra round. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_satisfied_by_registered_shell_send() { + let llm = spawn_capturing_llm(vec![ + openai_shell_send("tc1"), + openai_text("posted"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await; + let sid = init_session_with_fake_mcp( + &mut h, + &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 2, + "a recognized send must not be nagged, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[1]), 0); + h.shutdown().await; +} + +/// A publish-shaped call to a shell tool that is *not registered* never runs — +/// preflight rejects it — so it must not disarm the guard. This is what the +/// `has`/`is_hook` checks in the predicate buy. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_ignores_unregistered_shell_tool() { + // FAKE_MCP_SHELL_TOOL is absent, so `fake__shell` is a hallucination. + let llm = spawn_capturing_llm(vec![ + openai_shell_send("tc1"), + openai_text("silent-1"), + openai_text("silent-2"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session_with_fake_mcp(&mut h, &[("FAKE_MCP_TOOL_COUNT", "1")]).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "expected the hallucinated call to still be nagged, got {} LLM calls", + captured.len() + ); + let msgs = captured[1]["messages"].as_array().unwrap(); + assert!( + msgs.iter() + .any(|m| m["role"] == "tool" + && m["content"].as_str().unwrap_or("").contains("unknown tool")), + "expected preflight to reject the call: {msgs:?}" + ); + assert_eq!(reply_nag_count(&captured[2]), 1); + h.shutdown().await; +} + +/// A publish-shaped call discarded by the per-turn tool-call cap never runs, +/// so it must not suppress the reminder either. Pins the check's placement +/// after `calls.truncate(MAX_TOOL_CALLS_PER_TURN)`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_ignores_calls_lost_to_the_turn_cap() { + // 64 filler calls (the cap) followed by the publish attempt, which is + // therefore truncated away. The shell tool *is* registered here, so only + // the placement — not tool identity — can explain the reminder. + let mut calls: Vec = (0..64) + .map(|i| { + json!({ + "id": format!("c{i}"), + "type": "function", + "function": { "name": "fake__tool_0", "arguments": "{}" }, + }) + }) + .collect(); + calls.push(json!({ + "id": "c-send", + "type": "function", + "function": { + "name": "fake__shell", + "arguments": json!({ "command": "buzz messages send --channel c --content hi" }) + .to_string(), + }, + })); + let truncated_send = json!({ + "id": "cc-trunc", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": null, "tool_calls": calls }, + "finish_reason": "tool_calls", + }], + }); + let llm = spawn_capturing_llm(vec![ + truncated_send, + openai_text("silent-1"), + openai_text("silent-2"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "a truncated send must still be nagged, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[2]), 1); + h.shutdown().await; +} + +/// The shared `_Stop` rejection budget is the outer cap: at 1 the guard gets +/// one reminder instead of two. Documented degradation, not a bug. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_bounded_by_stop_rejection_budget() { + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 2, + "budget 1 must allow exactly one reminder, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[1]), 1); + h.shutdown().await; +} + +/// Budget 0 disables every objection at the gate, including this one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_off_when_stop_budget_is_zero() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "budget 0 must disable the guard, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// The two axes are independent inside one shared budget: a round carrying +/// both a `_Stop` hook objection and a reminder costs one rejection and +/// delivers both texts, and once the reminders are spent the hook objection +/// continues alone. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_combines_with_stop_hook_objection() { + // The hook objects on its first 3 calls, then clears. Reminders stop + // after 2, so round 3 must carry the hook text and no new reminder. + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("silent-3"), + openai_text("silent-4"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("MCP_HOOK_SERVERS", "fake"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + ("FAKE_MCP_STOP_TEXT", "you have open todos"), + ("FAKE_MCP_STOP_COUNT", "3"), + ], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 4, + "expected 3 objecting rounds then a clear end, got {}", + captured.len() + ); + + let hook_objections = |req: &Value| -> usize { + req["messages"] + .as_array() + .map(|msgs| { + msgs.iter() + .filter(|m| { + m["content"] + .as_str() + .unwrap_or("") + .contains("you have open todos") + }) + .count() + }) + .unwrap_or(0) + }; + + // Round 2 carries one of each — a single rejection bought both texts. + assert_eq!(reply_nag_count(&captured[1]), 1); + assert_eq!(hook_objections(&captured[1]), 1); + // Round 4: the hook objected three times, the guard only twice. + assert_eq!(reply_nag_count(&captured[3]), 2); + assert_eq!(hook_objections(&captured[3]), 3); + h.shutdown().await; +} + +/// An unparseable toggle is a startup error, not a silent default. `parse_env` +/// is generic over `FromStr`, so this also pins the numeric type: a `bool` +/// field would have rejected the documented `1`. +#[test] +fn reply_guard_rejects_unparseable_toggle() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_buzz-agent")) + .env("BUZZ_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("BUZZ_AGENT_REQUIRE_REPLY", "true") + .stdin(Stdio::null()) + .output() + .expect("run buzz-agent"); + assert!( + !out.status.success(), + "expected a config error exit, got {:?}", + out.status + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("BUZZ_AGENT_REQUIRE_REPLY"), + "expected the offending key in the error, got: {stderr}" + ); +} diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a2dcdce6d2..6fd214925c 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -10,16 +10,26 @@ cargo install --path crates/buzz-cli ## Authentication -| Env Var | Mode | Use Case | -|---------|------|----------| -| `BUZZ_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair | +| Source | Mode | Use Case | +|--------|------|----------| +| `BUZZ_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair (preferred) | +| `--private-key-file PATH` | same | Secrets on disk (mode `0600`) | +| `--private-key-stdin` | same | Piping from a password manager | +| `--private-key` | same | **Deprecated** — leaks into shell history and `ps` | ```bash # Private key identity (NIP-98 signed requests) export BUZZ_PRIVATE_KEY="nsec1..." buzz channels list + +# Or keep the secret out of argv / the environment of child processes: +buzz --private-key-file ~/.config/buzz/nsec channels list +printf '%s' "$NSEC" | buzz --private-key-stdin channels list ``` +Do not pass `--private-key nsec1...` on the command line — see +[block/buzz#4032](https://github.com/block/buzz/issues/4032). + ## Usage All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other, 5=write conflict. diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 77234b7faa..71503c61cc 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -76,6 +76,7 @@ Export: ```bash export BUZZ_RELAY_URL="http://localhost:3000" export BUZZ_PRIVATE_KEY="nsec1..." # from the mint output +# Prefer file/stdin in shared shells: buzz --private-key-file /tmp/nsec … ``` ### Scope reference diff --git a/crates/buzz-cli/src/commands/doctor.rs b/crates/buzz-cli/src/commands/doctor.rs new file mode 100644 index 0000000000..df8cda75f8 --- /dev/null +++ b/crates/buzz-cli/src/commands/doctor.rs @@ -0,0 +1,645 @@ +//! `buzz doctor` — non-mutating preflight diagnostics. +//! +//! Unlike every other subcommand, doctor intentionally does NOT require +//! credentials: missing or malformed config must surface as a check result +//! (status `error`) rather than an argument-time usage failure. The command +//! never publishes events and never mutates local or remote state; +//! `--offline` runs only local checks and marks remote probes as skipped. +//! +//! Output is structured JSON on stdout: +//! +//! ```json +//! {"status":"warning","checks":[{"id":"identity","status":"ok","message":"..."}]} +//! ``` +//! +//! Exit behavior distinguishes a failed required check from warnings: +//! `0` when every applicable check is `ok` (warnings allowed), +//! `3` when any check is `error` and at least one error is auth/identity, +//! `2` when any check is `error` and all errors are relay/network, and +//! `1` for any other failure mix. Skipped checks never affect the exit +//! code. The human-readable `status` field is `ok`, `warning`, or `error` +//! mirroring the worst check outcome. + +use serde::Serialize; +use serde_json::json; + +use crate::client::{normalize_relay_url, BuzzClient}; +use crate::error::CliError; +use crate::private_key::{self, PrivateKeyInputs}; + +/// Status of a single doctor check. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +enum Status { + Ok, + Warning, + Error, + Skipped, +} + +fn identity_from_inputs(inputs: PrivateKeyInputs) -> Result { + let key = private_key::resolve_private_key(inputs)?; + nostr::Keys::parse(&key).map_err(|e| CliError::Key(format!("invalid private key: {e}"))) +} + +impl Status { + fn as_str(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Warning => "warning", + Self::Error => "error", + Self::Skipped => "skipped", + } + } +} + +/// Serialisable form of one check for the JSON output. +#[derive(Debug, Serialize)] +struct Check { + id: &'static str, + status: Status, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + remediation: Option, +} + +impl Check { + fn ok(id: &'static str, message: impl Into) -> Self { + Self { + id, + status: Status::Ok, + message: message.into(), + remediation: None, + } + } + + fn warning(id: &'static str, message: impl Into) -> Self { + Self { + id, + status: Status::Warning, + message: message.into(), + remediation: None, + } + } + + fn error(id: &'static str, message: impl Into, remediation: Option<&str>) -> Self { + Self { + id, + status: Status::Error, + message: message.into(), + remediation: remediation.map(str::to_string), + } + } + + fn skipped(id: &'static str, message: impl Into) -> Self { + Self { + id, + status: Status::Skipped, + message: message.into(), + remediation: None, + } + } +} + +/// Entry point invoked from `crate::run` before the credential gate. +/// +/// The doctor never returns `Err` for missing configuration; it reports +/// the problem as a check and exits non-zero via `run_from_args`. +pub async fn run( + cli: &crate::Cli, + offline: bool, + private_key_from_argv: bool, +) -> Result<(), CliError> { + let mut checks: Vec = Vec::new(); + + // ---- local checks ---- + + // `cli.relay` itself is a free-form string; canonicalize before probing + // so the rest of the doctor matches the rest of the CLI. + let relay_url_raw = cli.relay.clone(); + let relay_url = normalize_relay_url(&relay_url_raw); + if relay_url.is_empty() { + checks.push(Check::error( + "relay_url", + "relay URL is empty", + Some("Set BUZZ_RELAY_URL or pass --relay"), + )); + } else if !(relay_url.starts_with("http://") || relay_url.starts_with("https://")) { + checks.push(Check::error( + "relay_url", + format!( + "relay URL {:?} is not an http(s) URL after canonicalization", + relay_url + ), + Some("Use http:// or https://; ws:// and wss:// are rewritten automatically"), + )); + } else { + checks.push(Check::ok( + "relay_url", + format!("relay URL canonicalized to {}", relay_url), + )); + } + + // identity check: parse the private key. Never echo the material. + let keys: Option = match identity_from_inputs(PrivateKeyInputs { + private_key: cli.private_key.clone(), + private_key_file: cli.private_key_file.clone(), + private_key_stdin: cli.private_key_stdin, + private_key_from_argv, + }) { + Ok(k) => { + let pk = k.public_key().to_hex(); + checks.push(Check::ok( + "identity", + format!("signing identity available: {pk}"), + )); + Some(k) + } + Err(e) => { + checks.push(Check::error( + "identity", + e.to_string(), + Some("Provide one private key via BUZZ_PRIVATE_KEY, --private-key-file, or --private-key-stdin"), + )); + None + } + }; + + // auth_tag check: parse only; never print the tag material. + let auth_tag: Option = match cli.auth_tag.as_deref() { + None => { + checks.push(Check::ok( + "auth_tag", + "no BUZZ_AUTH_TAG set (optional; only required for delegated agents)", + )); + None + } + Some("") => { + checks.push(Check::ok( + "auth_tag", + "BUZZ_AUTH_TAG is empty (treated as unset)", + )); + None + } + Some(raw) => match buzz_sdk::nip_oa::parse_auth_tag(raw) { + Ok(tag) => match &keys { + Some(k) => match buzz_sdk::nip_oa::verify_auth_tag(raw, &k.public_key()) { + Ok(_) => { + checks.push(Check::ok( + "auth_tag", + "BUZZ_AUTH_TAG parsed and verified for this identity", + )); + Some(tag) + } + Err(e) => { + checks.push(Check::error( + "auth_tag", + format!("BUZZ_AUTH_TAG failed verification: {e}"), + Some("Re-issue the auth tag for the current identity"), + )); + None + } + }, + None => { + // Cannot verify without an identity; report as warning. + checks.push(Check::warning( + "auth_tag", + "BUZZ_AUTH_TAG present but cannot be verified without a valid identity", + )); + Some(tag) + } + }, + Err(e) => { + checks.push(Check::error( + "auth_tag", + format!("BUZZ_AUTH_TAG is malformed: {e}"), + Some("Re-export the auth tag JSON from the owning desktop"), + )); + None + } + }, + }; + + // version check: always available, no I/O. + checks.push(Check::ok( + "version", + format!("buzz-cli {}", env!("CARGO_PKG_VERSION")), + )); + + // ---- remote checks ---- + + if offline { + checks.push(Check::skipped("relay_reachable", "skipped (--offline)")); + checks.push(Check::skipped("nip11", "skipped (--offline)")); + checks.push(Check::skipped("auth_read", "skipped (--offline)")); + checks.push(Check::skipped("membership", "skipped (--offline)")); + } else { + run_remote_checks( + &relay_url, + keys.as_ref(), + auth_tag.as_ref(), + cli, + &mut checks, + ) + .await; + } + + // ---- output ---- + emit_and_exit(&checks) +} + +async fn run_remote_checks( + relay_url: &str, + keys: Option<&nostr::Keys>, + auth_tag: Option<&nostr::Tag>, + cli: &crate::Cli, + checks: &mut Vec, +) { + // Build a client only when we have a parseable identity; local checks + // already reported the identity problem, so the remote probes degrade + // to skipped/errored rather than re-diagnosing it. + let authed_client: Option = match keys { + Some(k) => match BuzzClient::new( + relay_url.to_string(), + k.clone(), + auth_tag.cloned(), + cli.auth_tag.clone(), + ) { + Ok(c) => Some(c), + Err(e) => { + checks.push(Check::error( + "relay_reachable", + format!("failed to build HTTP client: {e}"), + None, + )); + None + } + }, + None => None, + }; + + // Unauthenticated reachability probe: try the public NIP-11 document. + // We do this with a bare reqwest client so that doctor works even when + // the identity is invalid — reachability is independent of auth. + // Track the outcome so we can avoid re-emitting the same error when + // the authed probe below also fails. + let relay_reachable = match probe_reachability(relay_url).await { + Ok(()) => { + checks.push(Check::ok( + "relay_reachable", + format!("relay at {relay_url} responded to an unauthenticated probe"), + )); + true + } + Err(msg) => { + checks.push(Check::error( + "relay_reachable", + msg, + Some("Check BUZZ_RELAY_URL, DNS, and network connectivity"), + )); + false + } + }; + + // NIP-11 probe. Public per spec; relay may also accept the metadata + // unauthenticated. The same probe result feeds both the reachability + // message above (already emitted) and the parsed metadata here. + match fetch_nip11(relay_url).await { + Ok(doc) => { + let name = doc + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let supported = doc + .get("supported_nips") + .and_then(|v| v.as_array()) + .map(|arr| arr.len()) + .unwrap_or(0); + checks.push(Check::ok( + "nip11", + format!("NIP-11 document parsed: {name} (advertises {supported} NIPs)"), + )); + } + Err(msg) => { + checks.push(Check::error( + "nip11", + msg, + Some("Verify the relay exposes / (or /info) as application/nostr+json"), + )); + } + } + + // Authenticated read probe + membership probe. Both require an identity; + // skip them cleanly if we don't have one rather than re-diagnosing. + match authed_client { + None => { + checks.push(Check::skipped( + "auth_read", + "skipped (no valid signing identity)", + )); + checks.push(Check::skipped( + "membership", + "skipped (no valid signing identity)", + )); + } + Some(client) => { + // auth_read: a small, bounded, read-only filter. Using + // kinds:[39002] (#p=self) doubles as the membership probe. + let my_pk = client.keys().public_key().to_hex(); + let filter = json!({ + "kinds": [39002], + "#p": [my_pk], + }); + match client.query_paginated(filter, 1).await { + Ok(events) => { + checks.push(Check::ok("auth_read", "authenticated read succeeded")); + // membership: any kind:39002 with #p=self means we're + // a member of at least one channel. + if events.is_empty() { + checks.push(Check::warning( + "membership", + "identity is not a member of any channel visible to this relay", + )); + } else { + checks.push(Check::ok( + "membership", + format!( + "identity has membership in at least one channel (saw {} event(s))", + events.len() + ), + )); + } + } + // True auth rejection — either an explicit Auth variant or a + // relay 401/403 — classifies as auth (exit 3). + Err(e @ CliError::Auth(_)) + | Err( + e @ CliError::Relay { + status: 401 | 403, .. + }, + ) => { + checks.push(Check::error( + "auth_read", + format!("relay rejected authentication: {e}"), + Some("Verify BUZZ_PRIVATE_KEY and BUZZ_AUTH_TAG"), + )); + checks.push(Check::skipped( + "membership", + "skipped (authentication failed)", + )); + } + // Anything else — network/transport or non-401 relay — means + // the relay couldn't be queried authoritatively. If the + // unauthenticated reachability probe already failed, the + // check list already carries that signal; don't double-report. + // Otherwise, this path means the relay was reachable publicly + // but rejected our authed call network-side: report as a + // relay-side failure (exit 2), NOT as an auth failure. + Err(e) => { + if relay_reachable { + checks.push(Check::error( + "relay_reachable", + format!("authenticated read failed: {e}"), + Some("Check relay availability, DNS, and network connectivity"), + )); + } + checks.push(Check::skipped( + "auth_read", + "skipped (relay unreachable for authenticated read)", + )); + checks.push(Check::skipped( + "membership", + "skipped (relay unreachable for authenticated read)", + )); + } + } + } + } +} + +/// Cheap reachability probe: GET `/` with the NIP-11 accept header. We +/// don't care about the body here; only that something HTTP 2xx came back. +async fn probe_reachability(relay_url: &str) -> Result<(), String> { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + let resp = http + .get(relay_url) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|e| format!("relay probe failed: {e}"))?; + if resp.status().is_success() { + Ok(()) + } else { + Err(format!("relay returned HTTP {}", resp.status())) + } +} + +/// Fetch and parse the relay's NIP-11 information document. +/// +/// We reuse `get_public` so redirect, timeout, and TLS behavior matches +/// the rest of the CLI. +async fn fetch_nip11(relay_url: &str) -> Result { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + // NIP-11 specifies GET on the relay URL itself with the metadata + // accept header. The `get_public` helper on a constructed client does + // exactly this; we inline the call here so doctor does not need an + // authed `BuzzClient`. + let resp = http + .get(relay_url) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|e| format!("NIP-11 probe failed: {e}"))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| format!("failed to read NIP-11 body: {e}"))?; + if !status.is_success() { + return Err(format!("relay returned HTTP {status}")); + } + serde_json::from_str(&body).map_err(|e| format!("NIP-11 document was not valid JSON: {e}")) +} + +/// Aggregate after running all checks and return the process-level +/// `CliError` (or `Ok` for success). +/// +/// Mapping (exposed for tests via `decide_outcome`): +/// - any `error` check mentioning `identity` or `auth_tag` or `auth_read` => `CliError::Auth` (exit 3) +/// - any other `error` check => `CliError::Relay` (exit 2) +/// - no `error` but any `warning` => `Ok(())` (exit 0) +/// - all `ok` / `skipped` => `Ok(())` (exit 0) +fn emit_and_exit(checks: &[Check]) -> Result<(), CliError> { + let overall = if checks.iter().any(|c| c.status == Status::Error) { + "error" + } else if checks.iter().any(|c| c.status == Status::Warning) { + "warning" + } else { + "ok" + }; + + let doc = json!({ + "status": overall, + "checks": checks.iter().map(|c| json!({ + "id": c.id, + "status": c.status.as_str(), + "message": c.message, + "remediation": c.remediation, + })).collect::>(), + }); + println!("{doc}"); + + if overall == "ok" || overall == "warning" { + return Ok(()); + } + + // Distinguish credential-shaped failures from transport failures. + let mut has_auth_error = false; + let mut first_other_error: Option<&Check> = None; + for c in checks { + if c.status != Status::Error { + continue; + } + match c.id { + "identity" | "auth_tag" | "auth_read" => has_auth_error = true, + _ => { + if first_other_error.is_none() { + first_other_error = Some(c); + } + } + } + } + + if has_auth_error { + return Err(CliError::Auth( + "one or more doctor checks failed (see JSON output for details)".into(), + )); + } + if let Some(c) = first_other_error { + return Err(CliError::Relay { + status: 0, + body: format!("doctor check {} failed: {}", c.id, c.message), + }); + } + Err(CliError::Other("one or more doctor checks failed".into())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::exit_code; + use std::io::Write; + + fn check_with(id: &'static str, status: Status) -> Check { + Check { + id, + status, + message: String::new(), + remediation: None, + } + } + + #[test] + fn identity_accepts_private_key_file() { + let mut file = tempfile::NamedTempFile::new().expect("temporary key file"); + writeln!(file, "{}", "1".repeat(64)).expect("write key"); + + assert!(identity_from_inputs(crate::private_key::PrivateKeyInputs { + private_key_file: Some(file.path().to_path_buf()), + ..Default::default() + }) + .is_ok()); + } + + #[test] + fn all_ok_emits_status_ok_and_exit_zero() { + let checks = vec![ + check_with("relay_url", Status::Ok), + check_with("identity", Status::Ok), + ]; + assert!(emit_and_exit(&checks).is_ok()); + } + + #[test] + fn warning_emits_warning_and_exit_zero() { + let checks = vec![ + check_with("relay_url", Status::Ok), + check_with("membership", Status::Warning), + ]; + assert!(emit_and_exit(&checks).is_ok()); + } + + #[test] + fn error_on_identity_maps_to_auth() { + let checks = vec![check_with("identity", Status::Error)]; + let err = emit_and_exit(&checks).unwrap_err(); + assert!(matches!(err, CliError::Auth(_))); + assert_eq!(exit_code(&err), 3); + } + + #[test] + fn error_on_auth_read_maps_to_auth() { + let checks = vec![check_with("auth_read", Status::Error)]; + let err = emit_and_exit(&checks).unwrap_err(); + assert!(matches!(err, CliError::Auth(_))); + assert_eq!(exit_code(&err), 3); + } + + #[test] + fn error_on_relay_reachable_maps_to_relay_exit_two() { + let checks = vec![check_with("relay_reachable", Status::Error)]; + let err = emit_and_exit(&checks).unwrap_err(); + assert!(matches!(err, CliError::Relay { status: 0, .. })); + assert_eq!(exit_code(&err), 2); + } + + #[test] + fn error_mixed_auth_wins_over_relay() { + let checks = vec![ + check_with("relay_reachable", Status::Error), + check_with("identity", Status::Error), + ]; + let err = emit_and_exit(&checks).unwrap_err(); + assert!(matches!(err, CliError::Auth(_))); + } + + #[test] + fn skipped_checks_do_not_affect_outcome() { + let checks = vec![ + check_with("relay_url", Status::Ok), + check_with("identity", Status::Ok), + check_with("auth_read", Status::Skipped), + check_with("membership", Status::Skipped), + ]; + assert!(emit_and_exit(&checks).is_ok()); + } + + #[test] + fn json_shape_has_status_and_checks_array() { + // Serialise directly to avoid printing; validate the shape line by + // line rather than relying on `println!` output. + let checks = [check_with("identity", Status::Ok)]; + let doc = serde_json::json!({ + "status": "ok", + "checks": checks.iter().map(|c| serde_json::json!({ + "id": c.id, + "status": c.status.as_str(), + "message": c.message, + "remediation": c.remediation, + })).collect::>(), + }); + assert_eq!(doc["status"].as_str(), Some("ok")); + let arr = doc["checks"].as_array().expect("checks is an array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["id"].as_str(), Some("identity")); + assert_eq!(arr[0]["status"].as_str(), Some("ok")); + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8691590636..c1adddd4a2 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod agents; pub mod channel_templates; pub mod channels; pub mod dms; +pub mod doctor; pub mod emoji; pub mod feed; pub mod issues; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0726406d29..3c1285f372 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent_management; mod client; mod commands; mod error; +mod private_key; mod validate; use clap::{Parser, Subcommand}; @@ -37,7 +38,11 @@ where // double-install returns Err and is harmless. let _ = rustls::crypto::ring::default_provider().install_default(); - let cli = match Cli::try_parse_from(args) { + let argv: Vec = args.into_iter().map(Into::into).collect(); + let private_key_from_argv = + private_key::private_key_flag_on_argv(argv.iter().filter_map(|s| s.to_str())); + + let cli = match Cli::try_parse_from(argv) { Ok(cli) => cli, Err(e) => { if e.use_stderr() { @@ -50,7 +55,7 @@ where } } }; - match run(cli).await { + match run(cli, private_key_from_argv).await { Ok(()) => 0, Err(e) => { error::print_error(&e); @@ -68,9 +73,13 @@ Buzz CLI — interact with a Buzz relay Configuration (flags override env vars): BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000] - BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required] + BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [preferred over --private-key] BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional] +Identity secrets: prefer BUZZ_PRIVATE_KEY, --private-key-file, or +--private-key-stdin. Passing --private-key on argv is deprecated (shell +history / process listings). + The 'pack' subcommand runs locally and does not require a relay connection. Exit codes: 0=ok 1=bad input 2=relay/network error 3=auth error 4=other 5=write conflict @@ -81,10 +90,20 @@ struct Cli { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "http://localhost:3000")] relay: String, - /// Nostr private key (hex or nsec). This is the CLI's identity. + /// Nostr private key (hex or nsec). Prefer `BUZZ_PRIVATE_KEY`, + /// `--private-key-file`, or `--private-key-stdin` — passing the secret on + /// argv leaks into shell history and `ps` (deprecated). #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] private_key: Option, + /// Read the Nostr private key from a file (mode 0600 recommended). + #[arg(long, value_name = "PATH")] + private_key_file: Option, + + /// Read the Nostr private key from stdin (trim surrounding whitespace). + #[arg(long, default_value_t = false)] + private_key_stdin: bool, + /// NIP-OA auth tag JSON (owner attestation). Injected into every signed event. #[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)] auth_tag: Option, @@ -236,6 +255,12 @@ enum Cmd { /// Community moderation — reports queue, bans, timeouts, audit trail #[command(subcommand)] Moderation(ModerationCmd), + /// Non-mutating preflight diagnostics for the CLI environment and relay + Doctor { + /// Run only local checks; mark remote checks as skipped + #[arg(long, default_value_t = false)] + offline: bool, + }, } #[derive(Clone, Copy, clap::ValueEnum)] @@ -1768,7 +1793,42 @@ pub enum ModerationCmd { }, } -async fn run(cli: Cli) -> Result<(), CliError> { +/// Normalize hand-authored `BUZZ_AUTH_TAG` input to strict JSON. +/// +/// `.env` files and shell exports sometimes carry the tag in the unquoted +/// shorthand `[auth,,,]` (quotes dropped by hand). +/// When the input is not valid JSON but is bracket-delimited, rewrite it as +/// a JSON array of the comma-separated fields (an empty field `,,` becomes +/// `""`, matching the canonical form `["auth","hex","","hex"]`). +/// +/// This is presentation-layer leniency at the configuration edge only: the +/// output is always fed through the SDK's strict `parse_auth_tag` / +/// `verify_auth_tag`, which enforce structure, hex, the conditions grammar, +/// and the BIP-340 signature. Inputs that are already valid JSON — or not +/// recognizable as the shorthand — are returned unchanged so the strict +/// parser reports the error on the original bytes. +fn normalize_auth_tag_input(input: &str) -> String { + let trimmed = input.trim(); + if serde_json::from_str::(trimmed).is_ok() { + return trimmed.to_owned(); + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let fields: Vec<&str> = trimmed[1..trimmed.len() - 1] + .split(',') + .map(str::trim) + .collect(); + // Only a plausible 4-field auth tag is rewritten; anything else is + // passed through untouched for the strict parser to reject with an + // error that references the caller's original input. + if fields.len() == 4 && !fields.iter().any(|f| f.contains('"')) { + // serde_json cannot fail serializing a Vec<&str>. + return serde_json::to_string(&fields).expect("string array serializes"); + } + } + trimmed.to_owned() +} + +async fn run(cli: Cli, private_key_from_argv: bool) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); // Pack commands are local-only — no relay connection needed. @@ -1779,26 +1839,47 @@ async fn run(cli: Cli) -> Result<(), CliError> { }; } + // Doctor runs its own credential/relay handling so that missing or + // malformed config surfaces as check results instead of argument-error + // exits. + if let Cmd::Doctor { offline } = cli.command { + return commands::doctor::run(&cli, offline, private_key_from_argv).await; + } + // Auth: private key is required for all relay operations. // The keypair IS the identity — no tokens, no other auth. - let private_key_str = cli.private_key.ok_or_else(|| { - CliError::Auth("BUZZ_PRIVATE_KEY is required (use --private-key or set env var)".into()) + let private_key_str = private_key::resolve_private_key(private_key::PrivateKeyInputs { + private_key: cli.private_key, + private_key_file: cli.private_key_file, + private_key_stdin: cli.private_key_stdin, + private_key_from_argv, })?; let keys = Keys::parse(&private_key_str) .map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; // NIP-OA: parse and verify the auth tag if provided. + // + // `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw + // shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input + // edge only. The SDK grammar and the `x-auth-tag` wire format stay strict + // JSON; all validation and signature verification happen on the strict + // path below, unchanged. let (auth_tag, auth_tag_json) = match cli.auth_tag { - Some(ref json) if !json.is_empty() => { - let tag = buzz_sdk::nip_oa::parse_auth_tag(json) + Some(ref input) if !input.is_empty() => { + let json = normalize_auth_tag_input(input); + let tag = buzz_sdk::nip_oa::parse_auth_tag(&json) .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?; - buzz_sdk::nip_oa::verify_auth_tag(json, &keys.public_key()).map_err(|e| { + buzz_sdk::nip_oa::verify_auth_tag(&json, &keys.public_key()).map_err(|e| { CliError::Auth(format!( "BUZZ_AUTH_TAG verification failed for pubkey {}: {e}", keys.public_key().to_hex() )) })?; - (Some(tag), Some(json.clone())) + // Canonical wire form derives from the parsed-and-verified tag + // (same shape as buzz-acp's RestClient), never from raw input. + let canonical = serde_json::to_string(tag.as_slice()) + .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?; + (Some(tag), Some(canonical)) } _ => (None, None), }; @@ -1827,6 +1908,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { 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"), + Cmd::Doctor { .. } => unreachable!("handled above"), } } @@ -1835,6 +1917,51 @@ mod tests { use super::*; use clap::CommandFactory; + /// Raw shorthand `[auth,hex,,hex]` normalizes to strict JSON; the empty + /// conditions field becomes `""`. + #[test] + fn normalize_auth_tag_raw_shorthand() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + + let raw = format!("[auth,{owner},,{sig}]"); + let json = normalize_auth_tag_input(&raw); + let parsed: Vec = serde_json::from_str(&json).expect("output must be JSON"); + assert_eq!(parsed, vec!["auth", &owner, "", &sig]); + + // With conditions and surrounding whitespace (shell/.env artifacts). + let raw = format!(" [auth, {owner} , kind=9, {sig}] \n"); + let json = normalize_auth_tag_input(&raw); + let parsed: Vec = serde_json::from_str(&json).expect("output must be JSON"); + assert_eq!(parsed, vec!["auth", &owner, "kind=9", &sig]); + } + + /// Valid JSON input passes through byte-identical (modulo outer trim) — + /// the normalizer must never rewrite well-formed input. + #[test] + fn normalize_auth_tag_json_passthrough() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + let json_in = serde_json::json!(["auth", owner, "kind=9", sig]).to_string(); + assert_eq!(normalize_auth_tag_input(&json_in), json_in); + } + + /// Inputs that are neither JSON nor a plausible 4-field shorthand pass + /// through unchanged, so the strict parser rejects the original bytes. + #[test] + fn normalize_auth_tag_leaves_garbage_untouched() { + for garbage in [ + "not a tag", + "[auth,too,few]", + "[a,b,c,d,e]", + r#"[auth,"quoted",x,y]"#, // quote chars => not the shorthand + "[]", + "{\"auth\":1}", + ] { + assert_eq!(normalize_auth_tag_input(garbage), garbage.trim()); + } + } + /// Smoke test: CLI definition is valid and parseable. #[test] fn cli_definition_is_valid() { @@ -1872,6 +1999,7 @@ mod tests { "canvas", "channels", "dms", + "doctor", "emoji", "feed", "issues", diff --git a/crates/buzz-cli/src/private_key.rs b/crates/buzz-cli/src/private_key.rs new file mode 100644 index 0000000000..2baa37470d --- /dev/null +++ b/crates/buzz-cli/src/private_key.rs @@ -0,0 +1,184 @@ +//! Resolve the CLI identity secret without requiring it on argv. +//! +//! Prefer `BUZZ_PRIVATE_KEY`, `--private-key-file`, or `--private-key-stdin`. +//! Bare `--private-key` remains accepted but warns: argv values land in shell +//! history and process listings (see block/buzz#4032). + +use crate::error::CliError; +use std::fs; +use std::io::{self, Read}; +use std::path::Path; + +/// Sources that can supply the Nostr private key for relay commands. +#[derive(Debug, Clone, Default)] +pub struct PrivateKeyInputs { + /// Value from `--private-key` / `BUZZ_PRIVATE_KEY` (clap merges both). + pub private_key: Option, + /// Path from `--private-key-file`. + pub private_key_file: Option, + /// When true, read a single line/key from stdin (`--private-key-stdin`). + pub private_key_stdin: bool, + /// True when `--private-key` appeared on argv (not only via the env var). + pub private_key_from_argv: bool, +} + +/// Resolve the private key string, applying preference order and deprecation. +pub fn resolve_private_key(inputs: PrivateKeyInputs) -> Result { + let mut sources = 0u8; + if inputs.private_key_file.is_some() { + sources += 1; + } + if inputs.private_key_stdin { + sources += 1; + } + if inputs.private_key.is_some() { + sources += 1; + } + if sources > 1 { + return Err(CliError::Usage( + "specify only one of --private-key-file, --private-key-stdin, or BUZZ_PRIVATE_KEY/--private-key" + .into(), + )); + } + + if let Some(path) = inputs.private_key_file.as_deref() { + return read_private_key_file(path); + } + + if inputs.private_key_stdin { + return read_private_key_stdin(); + } + + if let Some(key) = inputs.private_key { + if inputs.private_key_from_argv { + eprintln!( + "warning: --private-key puts the secret in shell history and process listings; \ +prefer BUZZ_PRIVATE_KEY, --private-key-file, or --private-key-stdin (see https://github.com/block/buzz/issues/4032)" + ); + } + let trimmed = key.trim().to_owned(); + if trimmed.is_empty() { + return Err(CliError::Auth( + "BUZZ_PRIVATE_KEY is empty (use --private-key-file, --private-key-stdin, or set env var)" + .into(), + )); + } + return Ok(trimmed); + } + + Err(CliError::Auth( + "BUZZ_PRIVATE_KEY is required (prefer --private-key-file / --private-key-stdin / env; \ +--private-key is deprecated)" + .into(), + )) +} + +fn read_private_key_file(path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = fs::metadata(path) { + let mode = meta.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + eprintln!( + "warning: private key file {} is group/world-accessible (mode {:o}); prefer chmod 600", + path.display(), + mode + ); + } + } + } + + let contents = fs::read_to_string(path).map_err(|e| { + CliError::Auth(format!( + "failed to read --private-key-file {}: {e}", + path.display() + )) + })?; + let trimmed = contents.trim().to_owned(); + if trimmed.is_empty() { + return Err(CliError::Auth(format!( + "--private-key-file {} is empty", + path.display() + ))); + } + Ok(trimmed) +} + +fn read_private_key_stdin() -> Result { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .map_err(|e| CliError::Auth(format!("failed to read --private-key-stdin: {e}")))?; + let trimmed = buf.trim().to_owned(); + if trimmed.is_empty() { + return Err(CliError::Auth( + "--private-key-stdin produced an empty key".into(), + )); + } + Ok(trimmed) +} + +/// Detect whether `--private-key` was present on argv (vs env-only). +pub fn private_key_flag_on_argv(args: I) -> bool +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter().any(|a| { + let a = a.as_ref(); + a == "--private-key" || a.starts_with("--private-key=") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn argv_detector_matches_flag_forms() { + assert!(private_key_flag_on_argv([ + "buzz", + "--private-key", + "nsec1x" + ])); + assert!(private_key_flag_on_argv(["buzz", "--private-key=nsec1x"])); + assert!(!private_key_flag_on_argv(["buzz", "channels", "list"])); + } + + #[test] + fn prefers_file_contents() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, " nsec1filekey ").unwrap(); + let key = resolve_private_key(PrivateKeyInputs { + private_key_file: Some(file.path().to_path_buf()), + ..Default::default() + }) + .unwrap(); + assert_eq!(key, "nsec1filekey"); + } + + #[test] + fn rejects_multiple_sources() { + let err = resolve_private_key(PrivateKeyInputs { + private_key: Some("nsec1a".into()), + private_key_stdin: true, + ..Default::default() + }) + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn env_value_without_argv_does_not_require_file() { + let key = resolve_private_key(PrivateKeyInputs { + private_key: Some("nsec1env".into()), + private_key_from_argv: false, + ..Default::default() + }) + .unwrap(); + assert_eq!(key, "nsec1env"); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index afec52305a..b1be7c5038 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -182,29 +182,43 @@ pub const P_GATED_KINDS: &[u32] = &[ /// or more than one `shared` tag) so no ambiguous heads can exist. pub const KIND_PERSONA: u32 = 30175; -/// Returns `true` if `kind` uses the author-only-unless-shared read model -/// (currently only `KIND_PERSONA` / 30175). +/// Kinds that use the author-only-unless-shared read model. /// /// Events of these kinds may only be delivered to foreign readers when the -/// event carries exactly `["shared", "true"]`. Used by all relay read -/// chokepoints: REQ historical delivery, live fan-out, COUNT fallback, -/// and the `ids`-lookup result gate. -pub fn is_persona_shared_kind(kind: u32) -> bool { - kind == KIND_PERSONA +/// event carries exactly `["shared", "true"]`. Every relay read chokepoint +/// consults this set: REQ historical delivery, live fan-out, COUNT fallback, +/// the `ids`-lookup result gate, both HTTP surfaces, and the pre-`LIMIT` SQL +/// visibility pushdown in `buzz-db`. +/// +/// Membership is a privacy decision, not a convenience: adding a kind here +/// makes its events invisible to foreign readers until their author opts in, +/// and the opt-in must be a `shared` TAG (not a content field) so that +/// toggling it leaves content bytes — and any content hash derived from them — +/// unchanged. +/// +/// `KIND_TEAM` (30176) is deliberately NOT a member. Its writers never emit +/// `shared`, so catalog opt-in semantics do not describe it; it needs +/// owner-private read semantics instead, which is a separate change. +pub const SHARED_GATED_KINDS: &[u32] = &[KIND_PERSONA, KIND_TEAM_CATALOG]; + +/// Returns `true` if `kind` uses the author-only-unless-shared read model +/// (see [`SHARED_GATED_KINDS`]). +pub fn is_shared_gated_kind(kind: u32) -> bool { + SHARED_GATED_KINDS.contains(&kind) } -/// Returns `true` if the event is a persona-shared-catalog kind AND the -/// requester is NOT the author AND the event does NOT carry `["shared", -/// "true"]`. All three conditions must hold to withhold the event. +/// Returns `true` if the event is a shared-gated kind AND the requester is NOT +/// the author AND the event does NOT carry `["shared", "true"]`. All three +/// conditions must hold to withhold the event. /// /// This is the per-event gate used by REQ historical delivery, live fan-out, /// and COUNT fallback paths. It is intentionally independent of -/// `is_author_only_event` — persona events with `["shared", "true"]` MUST +/// `is_author_only_event` — shared-gated events with `["shared", "true"]` MUST /// reach foreign readers; stripping them at the author-only layer would break /// the catalog query. -pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { +pub fn is_unshared_gated_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { let kind = event.kind.as_u16() as u32; - if !is_persona_shared_kind(kind) { + if !is_shared_gated_kind(kind) { return false; } // Author reads are always allowed. @@ -212,18 +226,23 @@ pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: & return false; } // Foreign reader: allowed only if the event is explicitly shared. - !persona_event_is_shared(event) + !event_is_shared(event) } /// Returns `true` if the event carries exactly one `["shared", "true"]` tag. /// +/// Kind-agnostic: this is purely the tag-shape predicate. The kind check lives +/// in [`is_shared_gated_kind`], so callers that need "is this event shared" +/// for a kind they already know (e.g. a client deciding whether its own +/// retained head is published) can use this directly. +/// /// Requires the tag to have exactly two elements so that a three-element shape /// like `["shared","true","extra"]` is NOT treated as shared. Ingest enforces /// the same exact shape, so a well-stored event either has no `shared` tag /// (author-only) or exactly one with precisely two elements and value `"true"` /// (community-readable). This helper fails closed on any non-exact shape /// independently of ingest guarantees. -pub fn persona_event_is_shared(event: &nostr::Event) -> bool { +pub fn event_is_shared(event: &nostr::Event) -> bool { let mut count = 0usize; for tag in event.tags.iter() { let parts = tag.as_slice(); @@ -258,6 +277,34 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +/// NIP-AP: Team Catalog projection (parameterized replaceable, owner-authored). +/// +/// The shareable projection of a team, addressed by `(pubkey, kind, d_tag)` +/// where `d_tag` is the team's stable id. Content is a versioned JSON body +/// carrying sanitized team fields plus ordered, EMBEDDED member definition +/// projections. +/// +/// # Why this is not a `shared` tag on [`KIND_TEAM`] +/// +/// A team's members live in kind 30175 events that are author-only unless +/// individually shared, so a foreign reader of a shared team could never +/// hydrate its members. This kind therefore embeds the member projections +/// rather than referencing them: the share is atomic, it covers built-in +/// members that have no 30175 head at all, it is immune to local-id/d-tag +/// divergence, and an unshared 30175 stays private. Kind 30176's wire body is +/// untouched, so device sync keeps its contract. +/// +/// # Access control +/// +/// Member of [`SHARED_GATED_KINDS`]: author-only unless the event carries +/// exactly `["shared", "true"]`. Ingest additionally requires exactly one +/// non-empty, bounded `d` tag — generic NIP-33 storage maps a missing `d` to +/// the empty coordinate, which would collapse every team into one slot. +/// +/// Content carries only sanitized fields: no env vars, no `respond_to` +/// allowlist pubkeys, no source or local ids, no filesystem paths, no secrets. +pub const KIND_TEAM_CATALOG: u32 = 30178; + // NIP-56 reporting /// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). /// @@ -562,6 +609,15 @@ pub const KIND_GIT_STATUS_CLOSED: u32 = 1632; /// NIP-34: Status — Draft. pub const KIND_GIT_STATUS_DRAFT: u32 = 1633; +/// NIP-MP: Multi-repo project — a named grouping of `kind:30617` repository +/// announcements (parameterized replaceable, d=project slug). +/// +/// Members are `a` tags holding `30617::` coordinates, so one +/// project may span repositories owned by different pubkeys. The signer gains no +/// authority over any member: push policy reads the repository's own +/// announcement, never a project. See `docs/nips/NIP-MP.md`. +pub const KIND_PROJECT: u32 = 30621; + /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ KIND_PROFILE, @@ -586,6 +642,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_TEAM_CATALOG, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -691,6 +748,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_PROJECT, ]; /// Returns `true` if `kind` is in the ephemeral range (20000–29999). @@ -784,9 +842,11 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000– const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 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_DM_VISIBILITY)); // 30622 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 @@ -858,64 +918,68 @@ mod tests { } } - // ── persona_event_is_shared / is_unshared_persona_event ────────────── + // ── event_is_shared / is_unshared_gated_event ──────────────────────── - fn make_persona_event(tags: &[&[&str]]) -> nostr::Event { + fn make_event_of_kind(kind: u32, tags: &[&[&str]]) -> nostr::Event { use nostr::{EventBuilder, Keys, Kind, Tag}; let keys = Keys::generate(); let tag_vec: Vec = tags .iter() .map(|parts| Tag::parse(parts.iter().copied()).unwrap()) .collect(); - EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "") + EventBuilder::new(Kind::Custom(kind as u16), "") .tags(tag_vec) .sign_with_keys(&keys) .unwrap() } + fn make_persona_event(tags: &[&[&str]]) -> nostr::Event { + make_event_of_kind(KIND_PERSONA, tags) + } + #[test] - fn persona_event_is_shared_true_tag() { + fn event_is_shared_true_tag() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]); - assert!(persona_event_is_shared(&ev)); + assert!(event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_no_tag() { + fn event_is_shared_no_tag() { let ev = make_persona_event(&[&["d", "my-agent"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_wrong_value() { + fn event_is_shared_wrong_value() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "false"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_duplicate_shared_tags() { + fn event_is_shared_duplicate_shared_tags() { // Two ["shared","true"] tags → ambiguous; not considered shared. let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"], &["shared", "true"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_three_element_tag_not_shared() { + fn event_is_shared_three_element_tag_not_shared() { // ["shared","true","extra"] — three elements — must NOT be treated as shared. // The helper fails closed on any non-exact shape independently of ingest guarantees. let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true", "extra"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_one_element_tag_not_shared() { + fn event_is_shared_one_element_tag_not_shared() { // ["shared"] — only one element — not shared (fails the == 2 check). let ev = make_persona_event(&[&["d", "my-agent"], &["shared"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn is_unshared_persona_event_author_always_allowed() { + fn is_unshared_gated_event_author_always_allowed() { // Even without a shared tag the event author should not be blocked. use nostr::{EventBuilder, Keys, Kind, Tag}; let keys = Keys::generate(); @@ -924,32 +988,83 @@ mod tests { .sign_with_keys(&keys) .unwrap(); let author_bytes = keys.public_key().to_bytes(); - assert!(!is_unshared_persona_event(&ev, &author_bytes)); + assert!(!is_unshared_gated_event(&ev, &author_bytes)); } #[test] - fn is_unshared_persona_event_foreign_no_tag() { + fn is_unshared_gated_event_foreign_no_tag() { let ev = make_persona_event(&[&["d", "my-agent"]]); let foreign = [0u8; 32]; - assert!(is_unshared_persona_event(&ev, &foreign)); + assert!(is_unshared_gated_event(&ev, &foreign)); } #[test] - fn is_unshared_persona_event_foreign_shared_tag() { + fn is_unshared_gated_event_foreign_shared_tag() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]); let foreign = [0u8; 32]; - assert!(!is_unshared_persona_event(&ev, &foreign)); + assert!(!is_unshared_gated_event(&ev, &foreign)); } #[test] - fn is_unshared_persona_event_non_persona_kind_passthrough() { + fn is_unshared_gated_event_ungated_kind_passthrough() { use nostr::{EventBuilder, Keys, Kind}; let keys = Keys::generate(); let ev = EventBuilder::new(Kind::Custom(KIND_TEAM as u16), "") .sign_with_keys(&keys) .unwrap(); let foreign = [0u8; 32]; - // Non-persona kinds are never blocked by this gate. - assert!(!is_unshared_persona_event(&ev, &foreign)); + // Kinds outside SHARED_GATED_KINDS are never blocked by this gate. + assert!(!is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_foreign_no_tag() { + // The gate must cover 30178 identically to 30175 — an unshared team + // catalog projection is author-only. + let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"]]); + let foreign = [0u8; 32]; + assert!(is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_foreign_shared_tag() { + let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"], &["shared", "true"]]); + let foreign = [0u8; 32]; + assert!(!is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_author_always_allowed() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let keys = Keys::generate(); + let ev = EventBuilder::new(Kind::Custom(KIND_TEAM_CATALOG as u16), "") + .tags(vec![Tag::parse(["d", "team-1"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let author_bytes = keys.public_key().to_bytes(); + assert!(!is_unshared_gated_event(&ev, &author_bytes)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_malformed_shared_tag_fails_closed() { + // A three-element `shared` tag can never be stored (ingest rejects it), + // but the read gate must independently treat it as NOT shared. + let ev = make_event_of_kind( + KIND_TEAM_CATALOG, + &[&["d", "team-1"], &["shared", "true", "extra"]], + ); + let foreign = [0u8; 32]; + assert!(is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn shared_gated_kinds_membership() { + assert!(is_shared_gated_kind(KIND_PERSONA)); + assert!(is_shared_gated_kind(KIND_TEAM_CATALOG)); + // 30176 has owner-private semantics, not catalog opt-in semantics: its + // writers never emit `shared`, so gating it here would hide every team + // from its own delegated readers. + assert!(!is_shared_gated_kind(KIND_TEAM)); + assert!(!is_shared_gated_kind(KIND_MANAGED_AGENT)); } } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 0e54196d11..a670a13402 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -11,12 +11,19 @@ use uuid::Uuid; use buzz_core::kind::{ event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, - KIND_HUDDLE_STARTED, + KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, }; use buzz_core::{CommunityId, StoredEvent}; use crate::error::{DbError, Result}; +/// Largest page [`query_events`] will return when [`EventQuery::max_limit`] is +/// unset — the effective ceiling on any client-requested `limit`. +/// +/// This is the value the relay advertises as NIP-11 `limitation.max_limit`, so +/// the advertised ceiling and the enforced one cannot drift. +pub const DEFAULT_MAX_PAGE_LIMIT: i64 = 1_000; + /// Optional filters for [`query_events`]. #[derive(Debug, Clone)] pub struct EventQuery { @@ -67,17 +74,19 @@ pub struct EventQuery { /// channel-less global events. Applied before SQL `LIMIT` so access-filtered /// historical pages have exact exhaustion semantics. pub channel_ids: Option>, - /// Override the default limit clamp (1000). Used by COUNT fallback path - /// which needs to fetch all matching events for post-filter counting. - /// When None, the default clamp of 1000 applies. + /// Override the default page clamp ([`DEFAULT_MAX_PAGE_LIMIT`]). Used by + /// the COUNT fallback path, which needs to fetch all matching events for + /// post-filter counting. When None, the default clamp applies. pub max_limit: Option, - /// Persona visibility reader: when set, append an SQL visibility clause - /// for kind 30175 before ORDER/LIMIT so private personas are excluded from - /// the candidate page rather than discarded after it. + /// Shared-gated visibility reader: when set, append an SQL visibility + /// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so + /// private events are excluded from the candidate page rather than + /// discarded after it. /// - /// The clause is: `AND (kind != 30175 OR pubkey = $reader OR tags @> ?)`, - /// where `?` is the JSONB literal `[["shared","true"]]`. The GIN index on - /// `tags` (migration 0004, jsonb_path_ops) makes the containment check fast. + /// The clause is: `AND (kind NOT IN (...) OR pubkey = $reader OR tags @> ?)`, + /// where the `IN` list is [`SHARED_GATED_KINDS`] and `?` is the JSONB + /// literal `[["shared","true"]]`. The GIN index on `tags` (migration 0004, + /// jsonb_path_ops) makes the containment check fast. /// /// NOTE: `tags @> '[["shared","true"]]'` uses JSONB containment, which /// matches any tag array that is a superset of `[["shared","true"]]` — it @@ -85,7 +94,7 @@ pub struct EventQuery { /// 2` exact-shape check ensures such malformed tags are never stored, so the /// SQL pushdown is sound. Keeping `event_visible_to_reader` as post-filter /// defense-in-depth catches any residual mismatch. - pub persona_reader: Option>, + pub shared_gated_reader: Option>, } impl EventQuery { @@ -114,7 +123,7 @@ impl EventQuery { e_tags: None, channel_ids: None, max_limit: None, - persona_reader: None, + shared_gated_reader: None, } } } @@ -355,7 +364,7 @@ pub(crate) async fn query_events_on( return Ok(vec![]); } - let clamp = q.max_limit.unwrap_or(1000); + let clamp = q.max_limit.unwrap_or(DEFAULT_MAX_PAGE_LIMIT); let limit_val = q.limit.unwrap_or(100).min(clamp); let offset_val = q.offset.unwrap_or(0); @@ -512,25 +521,28 @@ pub(crate) async fn query_events_on( } } - // Persona visibility pushdown: exclude kind 30175 events that are neither - // authored by the reader nor explicitly shared. Applied BEFORE ORDER/LIMIT - // so that a page of newer private personas does not push visible shared ones - // off the end of the result set (the catalog query pattern). + // Shared-gated visibility pushdown: exclude SHARED_GATED_KINDS events that + // are neither authored by the reader nor explicitly shared. Applied BEFORE + // ORDER/LIMIT so that a page of newer private events does not push visible + // shared ones off the end of the result set (the catalog query pattern). // - // Clause: AND (kind != 30175 OR pubkey = $reader OR tags @> '[["shared","true"]]') + // Clause: AND (kind NOT IN (30175, 30178) OR pubkey = $reader + // OR tags @> '[["shared","true"]]') // // The JSONB containment check is served by idx_events_tags_gin (migration // 0004, jsonb_path_ops). `tags @> '[["shared","true"]]'` matches any array // that contains exactly the sub-array — a two-element `["shared","true"]` - // tag passes; a tag-absent event does not. Because ingest now requires - // exactly two elements for the shared tag (parts.len() == 2), no stored - // event can carry a three-element superset. - if let Some(ref reader_bytes) = q.persona_reader { - let kind_30175: i32 = 30175; + // tag passes; a tag-absent event does not. Because ingest requires exactly + // two elements for the shared tag (parts.len() == 2), no stored event can + // carry a three-element superset. + if let Some(ref reader_bytes) = q.shared_gated_reader { let shared_containment = serde_json::json!([["shared", "true"]]); - qb.push(format!(" AND ({col_prefix}kind != ")); - qb.push_bind(kind_30175); - qb.push(format!(" OR {col_prefix}pubkey = ")); + qb.push(format!(" AND ({col_prefix}kind NOT IN (")); + let mut sep = qb.separated(", "); + for kind in SHARED_GATED_KINDS { + sep.push_bind(*kind as i32); + } + qb.push(format!(") OR {col_prefix}pubkey = ")); qb.push_bind(reader_bytes.clone()); qb.push(format!(" OR {col_prefix}tags @> ")); qb.push_bind(shared_containment); @@ -777,7 +789,8 @@ pub async fn soft_delete_event( } /// Soft-delete the live row for an addressable coordinate -/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key. +/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key — provided it is not +/// newer than the deletion request. /// /// Used by `handle_a_tag_deletion` to honour NIP-09 a-tag deletions for any /// parameterized-replaceable kind. The WHERE clause mirrors @@ -785,23 +798,45 @@ pub async fn soft_delete_event( /// `channel_id` is intentionally NOT in the key (NIP-33 replacement is global /// per the spec — `channel_id` is stored for query scoping, not identity). /// +/// `deletion_created_at_secs` is the deletion event's own `created_at`. NIP-09 +/// scopes an `a`-tag deletion to versions at or before that instant, so a +/// delayed or replayed tombstone signed between two versions must not erase the +/// newer replacement. `events.created_at` is immutable per row, so the predicate +/// guarantees a tombstone can never erase a version newer than itself — the UPDATE +/// re-evaluates its WHERE clause after any lock wait, so a replacement that races +/// the deletion and lands with a later `created_at` is always spared. +/// +/// This does NOT guarantee deletion completeness when a same-coordinate +/// replacement races the deletion: the deletion may evaluate its predicate before +/// the replacement arrives, miss the incoming head, and return `Ok(false)`. That +/// outcome is state-identical to the deletion having arrived first (old head +/// gone, new head present), which is a valid Nostr ordering — Nostr never fixes +/// the order of concurrent writes from different signers, and even same-signer +/// ordering is advisory. The return value feeds only a debug log, not a +/// correctness gate. +/// /// Returns `Ok(true)` if a row was deleted, `Ok(false)` if no live row matched -/// (already deleted, or never existed). +/// (already deleted, never existed, or strictly newer than the deletion). pub async fn soft_delete_by_coordinate( pool: &PgPool, community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, + deletion_created_at_secs: i64, ) -> Result { + let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?; let result = 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", + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ + AND created_at <= $5", ) .bind(community_id.as_uuid()) .bind(kind) .bind(pubkey) .bind(d_tag) + .bind(deletion_created_at) .execute(pool) .await?; diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 0c6ea36dac..9b26876747 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -55,7 +55,7 @@ pub mod user; pub mod workflow; pub use error::{DbError, Result}; -pub use event::{EventQuery, ReactionEventInsertOutcome}; +pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT}; use chrono::{DateTime, Utc}; use sqlx::postgres::{PgConnection, PgPoolOptions}; @@ -1813,16 +1813,27 @@ impl Db { event::soft_delete_event(&self.pool, community_id, event_id).await } - /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`. - /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds. + /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` + /// when it is not newer than the deletion request. + /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; + /// `deletion_created_at_secs` is the deletion event's `created_at`. pub async fn soft_delete_by_coordinate( &self, community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, + deletion_created_at_secs: i64, ) -> Result { - event::soft_delete_by_coordinate(&self.pool, community_id, kind, pubkey, d_tag).await + event::soft_delete_by_coordinate( + &self.pool, + community_id, + kind, + pubkey, + d_tag, + deletion_created_at_secs, + ) + .await } /// Atomically soft-delete an event and decrement thread reply counters. @@ -3988,8 +3999,33 @@ impl Db { } /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. + /// + /// Replica-routed on the bounded arm — the one PERMISSION read routed by + /// explicit product decision (bounded-stale membership beats the 10s + /// cache it replaced). Admits and revokes may lag by at most the budget + /// `B`; everything else fails closed to the writer, exactly like + /// [`Db::query_events_routed_bounded`]. Not precedent for routing other + /// permission reads. pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { - relay_members::is_relay_member(&self.pool, community, pubkey).await + let path = "relay_membership"; + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match relay_members::is_relay_member_on(&mut tx, community, pubkey).await { + Ok(is_member) => { + Self::record_route(path, "replica", reason); + Ok(is_member) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + relay_members::is_relay_member(&self.pool, community, pubkey).await + } + } + } + RouteDecision::Writer => { + relay_members::is_relay_member(&self.pool, community, pubkey).await + } + } } /// Returns the relay member record for `pubkey` in `community`, or `None` if not found. @@ -4085,6 +4121,12 @@ impl Db { relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await } + /// Returns `true` if any member of `community` holds the `admin` or + /// `owner` role. + pub async fn has_admin_or_owner(&self, community: CommunityId) -> Result { + relay_members::has_admin_or_owner(&self.pool, community).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 @@ -5227,6 +5269,75 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn coordinate_delete_spares_head_newer_than_the_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 kind = buzz_core::kind::KIND_PROJECT as i32; + let d_tag = "stale-tombstone-project"; + let pubkey = keys.public_key().to_bytes().to_vec(); + let base = Timestamp::now().as_secs(); + + let version = |content: &str, offset: u64| { + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) + .tags(vec![Tag::parse(["d", d_tag]).expect("d tag")]) + .custom_created_at(Timestamp::from(base + offset)) + .sign_with_keys(&keys) + .expect("sign project version") + }; + + for (content, offset) in [("v1", 0), ("v2", 100)] { + assert!( + db.replace_parameterized_event(community, &version(content, offset), d_tag, None) + .await + .expect("store project version") + .1 + ); + } + + // Tombstone timestamped between V1 and V2: it authorizes deleting V1, + // never the newer head that replaced it. + let stale_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 50) as i64) + .await + .expect("stale coordinate delete"); + assert!( + !stale_deleted, + "a tombstone older than the live head must delete nothing" + ); + + let live_content: Option = sqlx::query_scalar( + "SELECT content FROM events \ + WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(kind) + .bind(&pubkey) + .bind(d_tag) + .fetch_optional(&db.pool) + .await + .expect("read live head"); + assert_eq!( + live_content.as_deref(), + Some("v2"), + "the newer head must survive a stale tombstone" + ); + + // A tombstone at or after the head's own timestamp still deletes it. + let current_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 100) as i64) + .await + .expect("current coordinate delete"); + assert!( + current_deleted, + "a tombstone at the head's timestamp must delete it (NIP-09 is at-or-before)" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() { @@ -5780,15 +5891,21 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn test_usage_metrics_lock_has_single_owner_and_releases_on_drop() { - let database_url = - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); - let pool = PgPoolOptions::new() - .max_connections(2) - .connect(&database_url) - .await - .expect("connect to test DB"); + // Use a private scratch database — not the shared TEST_DATABASE_URL. + // Postgres advisory locks are per-database; hardcoding the production + // USAGE_METRICS_LOCK_KEY (0x4255_5A5A_4D45_5452) on the shared test DB + // races any live buzz-relay on the same database (see #3619). + let admin_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&admin_url) + .await + .expect("connect admin to create scratch db"); + let (pool, scratch_name) = create_scratch_db(&admin, "usage_metrics_lock").await; let first = Db::from_pool(pool.clone()); - let second = Db::from_pool(pool); + let second = Db::from_pool(pool.clone()); + // Same key as production (`buzz-relay` USAGE_METRICS_LOCK_KEY) — safe here + // because the scratch DB is empty of other holders. let key = 0x4255_5A5A_4D45_5452; let mut leader = first @@ -5815,6 +5932,11 @@ mod tests { .is_some(), "dropping the detached session releases its advisory lock" ); + + // Release any remaining session state before DROP DATABASE. + drop(first); + drop(second); + drop_scratch_db(&admin, pool, &scratch_name).await; } #[tokio::test] @@ -7317,6 +7439,78 @@ mod tests { drop_scratch_db(&admin, writer, &wname).await; } + /// Routed relay-membership check: budget unset ⇒ writer; budget set + + /// fresh proved entry ⇒ replica (bounded arm); over-budget entry ⇒ + /// writer. Divergent membership rows prove which pool answered. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn is_relay_member_is_bounded_routed_and_fails_closed() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "mem_w").await; + let (replica, rname) = create_scratch_db(&admin, "mem_r").await; + + let community = Uuid::new_v4(); + for pool in [&writer, &replica] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("member-routing-{}.example", community.simple())) + .execute(pool) + .await + .expect("insert community"); + } + let cid = CommunityId::from_uuid(community); + let writer_only = "aa".repeat(32); + let replica_only = "bb".repeat(32); + relay_members::add_relay_member(&writer, cid, &writer_only, "member", None) + .await + .expect("seed writer member"); + relay_members::add_relay_member(&replica, cid, &replica_only, "member", None) + .await + .expect("seed replica member"); + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + + // Budget unset ⇒ bounded arm disabled ⇒ writer. + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("gate off"), + "budget unset must answer from the writer" + ); + assert!(!db.is_relay_member(cid, &replica_only).await.unwrap()); + + // Budget set + fresh entry ⇒ replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + assert!( + db.is_relay_member(cid, &replica_only) + .await + .expect("gate on"), + "budget set must answer from the replica" + ); + assert!(!db.is_relay_member(cid, &writer_only).await.unwrap()); + + // Entry older than the budget ⇒ fail closed to the writer. Close + // first so no prior fresh entry can be the one proved (matches the + // count test; today `force_open_for_tests_at` also clears the ring). + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("entry too old"), + "an over-budget entry must fail closed to the writer" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + /// Community separation across every routed seam, verified on /// REPLICA-SERVED reads. /// diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 3805745f9b..402229cdec 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -29,14 +29,41 @@ pub struct RelayMember { /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. pub async fn is_relay_member(pool: &PgPool, community: CommunityId, pubkey: &str) -> Result { + let mut conn = pool.acquire().await?; + is_relay_member_on(&mut conn, community, pubkey).await +} + +/// [`is_relay_member`] on a specific session — the replica-routing path runs +/// the lookup on the exact reader connection whose heartbeat observation +/// proved fence coverage. +pub(crate) async fn is_relay_member_on( + conn: &mut sqlx::PgConnection, + community: CommunityId, + pubkey: &str, +) -> Result { let row = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(conn) .await?; Ok(row.is_some()) } +/// Returns `true` if any member of `community` holds the `admin` or `owner` +/// role. Open relays don't *enforce* the roster, but startup +/// (`bootstrap_owner`) and operator provisioning still populate it — this is +/// how the workspace-profile gate detects whether a steward exists. +pub async fn has_admin_or_owner(pool: &PgPool, community: CommunityId) -> Result { + let row = sqlx::query( + "SELECT 1 FROM relay_members \ + WHERE community_id = $1 AND role IN ('admin', 'owner') LIMIT 1", + ) + .bind(community.as_uuid()) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + /// Returns the relay member record for `pubkey` in `community`, or `None`. pub async fn get_relay_member( pool: &PgPool, @@ -376,7 +403,7 @@ pub enum TransferResult { /// Default 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; +pub const MAX_COMMUNITIES_PER_OWNER: i64 = 5; /// Effective per-owner community limit for this deployment. /// diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index eae8c5ef9e..4f1690beef 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -328,7 +328,7 @@ impl PubSubManager { publisher::publish_event(&self.pool, ctx, topic, event).await } - /// Set presence with 60s TTL. Call on connect and every 30s heartbeat. + /// Set presence with 180s TTL. Call on connect and every 60s heartbeat. pub async fn set_presence( &self, ctx: &TenantContext, diff --git a/crates/buzz-pubsub/src/presence.rs b/crates/buzz-pubsub/src/presence.rs index 178ba7550a..e0c9dfd6c9 100644 --- a/crates/buzz-pubsub/src/presence.rs +++ b/crates/buzz-pubsub/src/presence.rs @@ -1,7 +1,7 @@ //! Presence tracking — online/away status with TTL. //! -//! Stored as `SET buzz:{community}:presence:{pubkey_hex} "online" EX 90`. -//! TTL is 3x the 30s heartbeat interval so a single missed heartbeat doesn't +//! Stored as `SET buzz:{community}:presence:{pubkey_hex} "online" EX 180`. +//! TTL is 3x the 60s heartbeat interval so a single missed heartbeat doesn't //! cause presence flap. Clean disconnect deletes immediately. use buzz_core::TenantContext; @@ -12,8 +12,8 @@ use std::collections::HashMap; use crate::error::PubSubError; use crate::topic::BUZZ_PREFIX; -/// 3x the 30s heartbeat — single missed heartbeat won't cause presence flap. -pub const PRESENCE_TTL_SECS: u64 = 90; +/// 3x the 60s heartbeat — single missed heartbeat won't cause presence flap. +pub const PRESENCE_TTL_SECS: u64 = 180; /// Returns the Redis key for the presence entry of `pubkey` under `ctx`. pub fn presence_key(ctx: &TenantContext, pubkey: &PublicKey) -> String { @@ -109,6 +109,12 @@ mod tests { TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host) } + #[test] + fn presence_ttl_is_three_one_minute_heartbeat_windows() { + assert_eq!(PRESENCE_TTL_SECS, 180); + assert_eq!(PRESENCE_TTL_SECS, 3 * 60); + } + #[test] fn test_presence_key_format() { let pubkey = make_pubkey(); diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 10461d8d46..a118ff453f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1236,10 +1236,10 @@ async fn query_events_authed( extract_channel_from_filter(filter), &accessible_channels, ); - // Persona visibility pushdown: must mirror WS REQ so that a page of newer - // private personas does not starve older shared ones off the candidate page. - if crate::handlers::req::filter_can_match_persona_shared_kinds(filter) { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: must mirror WS REQ so that a page of + // newer private events does not starve older shared ones off the page. + if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } match extract_before_id(raw) { @@ -1453,11 +1453,11 @@ async fn count_events_authed( filter, &authed_pubkey_hex, ); - // Force per-event fallback for filters that can match kind:30175 — - // the fast SQL count_events() path has no per-event gate and would - // over-count foreign unshared persona events (existence leak). - let needs_persona_filtering = - crate::handlers::req::filter_can_match_persona_shared_kinds(filter); + // Force per-event fallback for filters that can match a shared-gated + // kind — the fast SQL count_events() path has no per-event gate and + // would over-count foreign unshared events (existence leak). + let needs_shared_gate_filtering = + crate::handlers::req::filter_can_match_shared_gated_kinds(filter); // If filter targets a specific channel, verify access. if let Some(ch_id) = extract_channel_from_filter(filter) { @@ -1472,10 +1472,10 @@ async fn count_events_authed( tenant.community(), ) .await; - // Persona visibility pushdown: same as REQ and /query paths, so the - // fallback's query_events call doesn't over-fetch private persona rows. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: same as REQ and /query paths, so + // the fallback's query_events call doesn't over-fetch private rows. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() @@ -1486,7 +1486,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { match state.db.count_events_routed("bridge_count", &query).await { Ok(n) => total += n as u64, @@ -1541,10 +1541,10 @@ async fn count_events_authed( ) .await; query.channel_ids = Some(accessible_channels.to_vec()); - // Persona visibility pushdown: pre-filter before ORDER/LIMIT on the - // fallback query_events path. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: pre-filter before ORDER/LIMIT on + // the fallback query_events path. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { @@ -1556,7 +1556,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { query.limit = None; match state.db.count_events_routed("bridge_count", &query).await { @@ -3042,6 +3042,27 @@ mod tests { assert_eq!(extract_page_offset(&raw, None), None); } + /// Offsets are sized from the *clamped* limit the DB will honor, not from + /// what the client asked for. `filter_to_query_params` clamps an absent or + /// over-ceiling `limit` to `DEFAULT_MAX_PAGE_LIMIT` (guarded in + /// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`) + /// and that clamped value is what arrives here — so page N starts exactly + /// N-1 full pages in. Sizing from an unclamped limit would step past rows + /// the previous page never returned. + #[test] + fn extract_page_offset_sizes_pages_from_clamped_limit() { + let clamped = buzz_db::DEFAULT_MAX_PAGE_LIMIT; + + assert_eq!( + extract_page_offset(&serde_json::json!({ "page": 2 }), Some(clamped)), + Some(clamped) + ); + assert_eq!( + extract_page_offset(&serde_json::json!({ "page": 3 }), Some(clamped)), + Some(clamped * 2) + ); + } + #[test] fn extract_depth_limit_valid() { let raw = serde_json::json!({ "depth_limit": 3 }); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 11c4f6d35b..f8e0300277 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -2795,10 +2795,18 @@ mod sec005_read_gate_tests { ); let owner_pk = f.owner_keys.public_key().to_bytes().to_vec(); + // Tombstone timestamped after the announcement, per NIP-09's + // at-or-before scoping in `soft_delete_by_coordinate`. let deleted = - f.db.soft_delete_by_coordinate(f.community, 30617, &owner_pk, &f.repo) - .await - .expect("soft delete 30617"); + f.db.soft_delete_by_coordinate( + f.community, + 30617, + &owner_pk, + &f.repo, + chrono::Utc::now().timestamp() + 60, + ) + .await + .expect("soft delete 30617"); assert!(deleted, "precondition: a live announcement row was deleted"); assert!( diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc26..0c28277e38 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -586,6 +586,22 @@ fn validate_media_path(sha256_ext: &str) -> Result<(), MediaError> { /// additional range requests. const MAX_RANGE_CHUNK: u64 = 16 * 1024 * 1024; +/// CSP for a blob response, kept least-privilege while allowing browser-native +/// viewers to load the response as their generated `` or `