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:
+
+[](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