diff --git a/.env.example b/.env.example index 3dc54856e7e..169f048679c 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,14 @@ RELAY_URL=ws://localhost:3000 # BUZZ_GIT_PACK_CACHE_MAX_BYTES=5368709120 # BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS=2 +# Production can replace S3/MinIO with Azure Blob Storage. AKS should provide +# AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_FEDERATED_TOKEN_FILE through +# workload identity; do not configure account keys. +# BUZZ_OBJECT_STORAGE_BACKEND=azure +# BUZZ_AZURE_STORAGE_ACCOUNT=buzzstorage +# BUZZ_AZURE_MEDIA_CONTAINER=buzz-media +# BUZZ_AZURE_GIT_CONTAINER=buzz-git + # ----------------------------------------------------------------------------- # Media Upload Admission # ----------------------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4826d985f5..c098ca22893 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1051,16 +1051,11 @@ jobs: - name: Build mesh llama native libraries if: steps.llama_cache.outputs.cache-hit != 'true' env: - MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }} + MESH_REV: ${{ steps.mesh_rev.outputs.rev }} run: | set -euo pipefail cargo fetch --manifest-path desktop/src-tauri/Cargo.toml - SHORT="$MESH_REV_SHORT" - MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1) - if [[ -z "$MESH_ROOT" ]]; then - echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch" - exit 1 - fi + MESH_ROOT="$(bash scripts/resolve-mesh-llm-checkout.sh "$MESH_REV")" export LLAMA_STAGE_BACKEND=metal export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal" export CMAKE_OSX_DEPLOYMENT_TARGET=10.15 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 52f21b28bc3..70e92feb829 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -179,7 +179,7 @@ jobs: cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} cache-to: | - ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} + ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} - name: Build and push debug image by digest id: build-debug @@ -402,7 +402,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }} - cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} + cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} - name: Export digest if: github.event_name != 'pull_request' env: diff --git a/.github/workflows/kiingo-desktop-release.yml b/.github/workflows/kiingo-desktop-release.yml new file mode 100644 index 00000000000..0664f014803 --- /dev/null +++ b/.github/workflows/kiingo-desktop-release.yml @@ -0,0 +1,449 @@ +name: Kiingo desktop release + +on: + workflow_dispatch: + inputs: + version: + description: Semver for the Kiingo desktop package (for example 0.5.1-kiingo.1) + required: true + type: string + source_revision: + description: Exact merged 40-character Kiingo/buzz commit to release + required: true + type: string + +permissions: + contents: write + id-token: write + +concurrency: + group: kiingo-desktop-production-release + cancel-in-progress: false + +env: + BUZZ_RELAY_URL: wss://chat.kiingo.com + BUZZ_RELAY_HTTP: https://chat.kiingo.com + BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY: '1' + BUZZ_BUILD_CODEX_ENROLLMENT_URL: https://app.kiingo.com/team/harness-connections?provider=codex&buzz=connect + BUZZ_UPDATER_ENDPOINT: https://github.com/Kiingo/buzz/releases/download/kiingo-desktop-latest/latest.json + +jobs: + validate: + name: Validate immutable release input + if: github.repository == 'Kiingo/buzz' + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + source_revision: ${{ steps.source.outputs.source_revision }} + version: ${{ steps.source.outputs.version }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - id: source + name: Require a merged exact source revision + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SOURCE_REVISION: ${{ inputs.source_revision }} + VERSION: ${{ inputs.version }} + shell: bash + run: | + set -euo pipefail + [[ "${GITHUB_REF_NAME}" == 'main' ]] || { + echo '::error::Kiingo production desktop releases run only from main.' + exit 1 + } + [[ "${SOURCE_REVISION}" =~ ^[a-f0-9]{40}$ ]] || { + echo '::error::source_revision must be an exact lowercase 40-character commit.' + exit 1 + } + [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { + echo '::error::version must be valid semver.' + exit 1 + } + git fetch origin main --depth=100 + git cat-file -e "${SOURCE_REVISION}^{commit}" + git merge-base --is-ancestor "${SOURCE_REVISION}" origin/main || { + echo '::error::source_revision is not merged into Kiingo/buzz main.' + exit 1 + } + if gh release view "kiingo-desktop-v${VERSION}" >/dev/null 2>&1; then + echo '::error::This immutable Kiingo desktop version already exists.' + exit 1 + fi + echo "source_revision=${SOURCE_REVISION}" >> "${GITHUB_OUTPUT}" + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + + macos: + name: Signed macOS (${{ matrix.platform }}) + needs: validate + environment: production + strategy: + fail-fast: false + matrix: + include: + - platform: darwin-aarch64 + target: aarch64-apple-darwin + - platform: darwin-x86_64 + target: x86_64-apple-darwin + runs-on: macos-14 + timeout-minutes: 90 + env: + VERSION: ${{ needs.validate.outputs.version }} + TARGET: ${{ matrix.target }} + BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.KIINGO_UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.KIINGO_TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.KIINGO_TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.KIINGO_APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.KIINGO_APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.KIINGO_APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.KIINGO_APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.KIINGO_APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.KIINGO_APPLE_TEAM_ID }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.validate.outputs.source_revision }} + fetch-depth: 0 + persist-credentials: false + + - name: Require signing and updater identities + shell: bash + run: | + set -euo pipefail + required=( + BUZZ_UPDATER_PUBLIC_KEY TAURI_SIGNING_PRIVATE_KEY + APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY + APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID + ) + for name in "${required[@]}"; do + [[ -n "${!name:-}" ]] || { + echo "::error::Missing protected Kiingo desktop release secret: ${name}" + exit 1 + } + done + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Install desktop dependencies + run: just desktop-install-ci + + - name: Configure immutable Kiingo release + shell: bash + run: | + set -euo pipefail + rustup target add "${TARGET}" + cd desktop + node scripts/set-version-from-tag.mjs "${VERSION}" + node scripts/build-release-config.mjs + + - name: Prohibit provider credentials in the package build + shell: bash + run: | + set -euo pipefail + forbidden=(OPENAI_API_KEY ANTHROPIC_API_KEY CODEX_HOME CODEX_API_KEY CODEX_CHATGPT_AUTH_JSON) + for name in "${forbidden[@]}"; do + [[ -z "${!name:-}" ]] || { + echo "::error::Provider credential environment is forbidden in a Kiingo Buzz desktop build: ${name}" + exit 1 + } + done + [[ -z "${BUZZ_BUILD_AGENT_ENV:-}" ]] || { + echo '::error::BUZZ_BUILD_AGENT_ENV must be empty for the Kiingo desktop package.' + exit 1 + } + + - name: Build sidecars + shell: bash + run: | + set -euo pipefail + cargo build --release --target "${TARGET}" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "${TARGET}" + + - name: Build, sign, notarize, and staple + shell: bash + run: cd desktop && pnpm tauri build --verbose --target "${TARGET}" --config src-tauri/tauri.release.conf.json + + - name: Verify package identity and embedded Kiingo contract + shell: bash + run: | + set -euo pipefail + bundle="desktop/src-tauri/target/${TARGET}/release/bundle" + app="${bundle}/macos/Buzz.app" + dmg="$(find "${bundle}/dmg" -name '*.dmg' -type f | head -1)" + [[ -d "${app}" && -f "${dmg}" ]] + codesign --verify --deep --strict --verbose=2 "${app}" + spctl --assess --type execute --verbose=4 "${app}" + xcrun stapler validate "${dmg}" + binary="${app}/Contents/MacOS/Buzz" + rg -a -q 'wss://chat\.kiingo\.com' "${binary}" + rg -a -q 'https://app\.kiingo\.com/team/harness-connections\?provider=codex&buzz=connect' "${binary}" + + - name: Stage signed release evidence + shell: bash + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + bundle="desktop/src-tauri/target/${TARGET}/release/bundle" + dmg="$(find "${bundle}/dmg" -name '*.dmg' -type f | head -1)" + archive="$(find "${bundle}/macos" -name '*.tar.gz' -type f | head -1)" + [[ -n "${dmg}" && -n "${archive}" && -f "${archive}.sig" ]] + mkdir -p staging + installer_name="Kiingo-Buzz_${VERSION}_${PLATFORM}.dmg" + archive_name="Kiingo-Buzz_${VERSION}_${PLATFORM}.app.tar.gz" + cp "${dmg}" "staging/${installer_name}" + cp "${archive}" "staging/${archive_name}" + cp "${archive}.sig" "staging/${PLATFORM}.sig" + jq -n \ + --arg platform "${PLATFORM}" \ + --arg installer "${installer_name}" \ + --arg archive "${archive_name}" \ + '{platform:$platform,installer:$installer,archive:$archive,signature_file:($platform + ".sig")}' \ + > staging/metadata.json + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: kiingo-desktop-${{ matrix.platform }} + path: staging + if-no-files-found: error + retention-days: 7 + + windows: + name: Signed Windows + needs: validate + environment: production + runs-on: windows-latest + timeout-minutes: 90 + env: + VERSION: ${{ needs.validate.outputs.version }} + TARGET: x86_64-pc-windows-msvc + BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.KIINGO_UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.KIINGO_TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.KIINGO_TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + ARTIFACT_SIGNING_ENDPOINT: ${{ vars.KIINGO_ARTIFACT_SIGNING_ENDPOINT }} + ARTIFACT_SIGNING_ACCOUNT: ${{ vars.KIINGO_ARTIFACT_SIGNING_ACCOUNT }} + ARTIFACT_SIGNING_PROFILE: ${{ vars.KIINGO_ARTIFACT_SIGNING_PROFILE }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.validate.outputs.source_revision }} + fetch-depth: 0 + persist-credentials: false + + - name: Require signing and updater identities + shell: bash + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + run: | + set -euo pipefail + required=( + BUZZ_UPDATER_PUBLIC_KEY TAURI_SIGNING_PRIVATE_KEY + AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID + ARTIFACT_SIGNING_ENDPOINT ARTIFACT_SIGNING_ACCOUNT ARTIFACT_SIGNING_PROFILE + ) + for name in "${required[@]}"; do + [[ -n "${!name:-}" ]] || { + echo "::error::Missing protected Kiingo desktop release setting: ${name}" + exit 1 + } + done + + - uses: azure/login@1384c340ab2dda50fed2bee3041d1d87018aa5e8 # v2.3.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: ${{ env.TARGET }} + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.14.1 + package-manager-cache: false + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 11.4.0 + + - name: Install desktop and signing dependencies + shell: bash + run: | + set -euo pipefail + pnpm install --frozen-lockfile + cargo install artifact-signing-cli --locked + + - name: Configure immutable Kiingo release + shell: bash + run: | + set -euo pipefail + export BUZZ_WINDOWS_SIGN_COMMAND="artifact-signing-cli -e ${ARTIFACT_SIGNING_ENDPOINT} -a ${ARTIFACT_SIGNING_ACCOUNT} -c ${ARTIFACT_SIGNING_PROFILE} -d Kiingo-Buzz %1" + cd desktop + node scripts/set-version-from-tag.mjs "${VERSION}" + node scripts/build-release-config.mjs + + - name: Prohibit provider credentials in the package build + shell: bash + run: | + set -euo pipefail + forbidden=(OPENAI_API_KEY ANTHROPIC_API_KEY CODEX_HOME CODEX_API_KEY CODEX_CHATGPT_AUTH_JSON) + for name in "${forbidden[@]}"; do + [[ -z "${!name:-}" ]] || { + echo "::error::Provider credential environment is forbidden in a Kiingo Buzz desktop build: ${name}" + exit 1 + } + done + [[ -z "${BUZZ_BUILD_AGENT_ENV:-}" ]] + + - name: Build sidecars + shell: bash + run: | + set -euo pipefail + cargo build --release --target "${TARGET}" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "${TARGET}" + + - name: Build and sign Windows package + shell: bash + run: | + set -euo pipefail + export BUZZ_WINDOWS_SIGN_COMMAND="artifact-signing-cli -e ${ARTIFACT_SIGNING_ENDPOINT} -a ${ARTIFACT_SIGNING_ACCOUNT} -c ${ARTIFACT_SIGNING_PROFILE} -d Kiingo-Buzz %1" + cd desktop + pnpm tauri build --verbose --target "${TARGET}" --bundles nsis --config src-tauri/tauri.release.conf.json + + - name: Verify Authenticode, timestamp, and embedded Kiingo contract + shell: pwsh + run: | + $bundle = "desktop/src-tauri/target/$env:TARGET/release/bundle" + $installer = Get-ChildItem -Path "$bundle/nsis" -Filter '*.exe' | Select-Object -First 1 + $app = Get-Item "desktop/src-tauri/target/$env:TARGET/release/buzz-desktop.exe" + if (-not $installer) { throw 'Signed NSIS installer was not produced.' } + foreach ($file in @($app, $installer)) { + $signature = Get-AuthenticodeSignature -LiteralPath $file.FullName + if ($signature.Status -ne 'Valid') { throw "Invalid Authenticode signature: $($file.FullName) ($($signature.Status))" } + if (-not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) { throw "Missing signer or RFC3161 timestamp: $($file.FullName)" } + } + rg -a -q 'wss://chat\.kiingo\.com' $app.FullName + if ($LASTEXITCODE -ne 0) { throw 'Pinned Kiingo relay URL is absent from the desktop executable.' } + rg -a -q 'https://app\.kiingo\.com/team/harness-connections\?provider=codex&buzz=connect' $app.FullName + if ($LASTEXITCODE -ne 0) { throw 'Codex enrollment URL is absent from the desktop executable.' } + + - name: Stage signed release evidence + shell: bash + run: | + set -euo pipefail + bundle="desktop/src-tauri/target/${TARGET}/release/bundle" + installer="$(find "${bundle}/nsis" -name '*.exe' -type f | head -1)" + [[ -n "${installer}" && -f "${installer}.sig" ]] + mkdir -p staging + platform='windows-x86_64' + installer_name="Kiingo-Buzz_${VERSION}_${platform}-setup.exe" + cp "${installer}" "staging/${installer_name}" + cp "${installer}" "staging/${installer_name}.updater" + cp "${installer}.sig" "staging/${platform}.sig" + jq -n \ + --arg platform "${platform}" \ + --arg installer "${installer_name}" \ + --arg archive "${installer_name}.updater" \ + '{platform:$platform,installer:$installer,archive:$archive,signature_file:($platform + ".sig")}' \ + > staging/metadata.json + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: kiingo-desktop-windows-x86_64 + path: staging + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish immutable packages and updater manifest + needs: [validate, macos, windows] + environment: production + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + VERSION: ${{ needs.validate.outputs.version }} + SOURCE_REVISION: ${{ needs.validate.outputs.source_revision }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.validate.outputs.source_revision }} + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: kiingo-desktop-* + path: dist + + - name: Verify complete signed platform set + shell: bash + run: | + set -euo pipefail + mapfile -t metadata < <(find dist -name metadata.json -type f | sort) + [[ "${#metadata[@]}" -eq 3 ]] || { + echo "::error::Expected three signed platform packages; found ${#metadata[@]}." + exit 1 + } + for file in "${metadata[@]}"; do + dir="$(dirname "${file}")" + installer="$(jq -r .installer "${file}")" + archive="$(jq -r .archive "${file}")" + sig="$(jq -r .signature_file "${file}")" + [[ -s "${dir}/${installer}" && -s "${dir}/${archive}" && -s "${dir}/${sig}" ]] + done + + - name: Create immutable and rolling releases + shell: bash + run: | + set -euo pipefail + gh release create "kiingo-desktop-v${VERSION}" \ + --target "${SOURCE_REVISION}" \ + --title "Kiingo Buzz Desktop ${VERSION}" \ + --notes "Signed Kiingo-internal Buzz desktop release pinned to chat.kiingo.com. Source: ${SOURCE_REVISION}." + if ! gh release view kiingo-desktop-latest >/dev/null 2>&1; then + gh release create kiingo-desktop-latest \ + --target "${SOURCE_REVISION}" \ + --title 'Kiingo Buzz Desktop updater channel' \ + --notes 'Rolling signed updater assets for Kiingo Buzz desktop clients.' \ + --prerelease + fi + + - name: Upload packages and assemble minimum-version update manifest + shell: bash + run: | + set -euo pipefail + triples=() + while IFS= read -r metadata; do + dir="$(dirname "${metadata}")" + platform="$(jq -r .platform "${metadata}")" + installer="$(jq -r .installer "${metadata}")" + archive="$(jq -r .archive "${metadata}")" + signature="$(jq -r .signature_file "${metadata}")" + gh release upload "kiingo-desktop-v${VERSION}" "${dir}/${installer}" + gh release upload kiingo-desktop-latest "${dir}/${archive}" "${dir}/${signature}" --clobber + url="https://github.com/Kiingo/buzz/releases/download/kiingo-desktop-latest/${archive}" + triples+=("${platform}:${dir}/${signature}:${url}") + done < <(find dist -name metadata.json -type f | sort) + bash desktop/scripts/generate-oss-latest-json.sh "${VERSION}" "${triples[@]}" > latest.json + jq -e --arg version "${VERSION}" '.version == $version and (.platforms | length) == 3' latest.json >/dev/null + gh release upload kiingo-desktop-latest latest.json --clobber + + - name: Publish release evidence summary + shell: bash + run: | + { + echo '### Kiingo Buzz desktop release' + echo + echo "- Version: ${VERSION}" + echo "- Source: ${SOURCE_REVISION}" + echo '- Relay: `wss://chat.kiingo.com`' + echo '- Platforms: signed/notarized macOS arm64 + x64; signed/timestamped Windows x64' + echo '- Provider credentials: prohibited by the release environment gate' + echo '- Update policy: signed Tauri updater manifest on `kiingo-desktop-latest`' + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 18d476e4002..98064433789 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -21,7 +21,7 @@ jobs: name: Build Linux canary if: github.repository == 'block/buzz' runs-on: ubuntu-latest - container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 timeout-minutes: 60 permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c613924e574..1d4ec56b195 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,16 +155,11 @@ jobs: - name: Build mesh llama native libraries if: steps.llama_cache.outputs.cache-hit != 'true' env: - MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }} + MESH_REV: ${{ steps.mesh_rev.outputs.rev }} run: | set -euo pipefail cargo fetch --manifest-path desktop/src-tauri/Cargo.toml - SHORT="$MESH_REV_SHORT" - MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1) - if [[ -z "$MESH_ROOT" ]]; then - echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch" - exit 1 - fi + MESH_ROOT="$(bash scripts/resolve-mesh-llm-checkout.sh "$MESH_REV")" export LLAMA_STAGE_BACKEND=metal export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal" export CMAKE_OSX_DEPLOYMENT_TARGET=10.15 @@ -476,7 +471,7 @@ jobs: if: github.repository == 'block/buzz' runs-on: ubuntu-latest # Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh. - container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 needs: setup timeout-minutes: 60 permissions: @@ -498,7 +493,7 @@ jobs: env: DEBIAN_FRONTEND: noninteractive run: | - # Must run first: bare ubuntu:22.04 ships without curl, wget, git, or + # Must run first: bare ubuntu:24.04 ships without curl, wget, git, or # ca-certificates. activate-hermit bootstraps via curl+HTTPS (needs # both), and actions/checkout falls back to a REST tarball without git. # Running as root — no sudo needed. diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index fb0656028af..2a65fc03826 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -116,15 +116,11 @@ jobs: - name: Build mesh llama native libraries if: steps.llama_cache.outputs.cache-hit != 'true' env: - MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }} + MESH_REV: ${{ steps.mesh_rev.outputs.rev }} run: | set -euo pipefail cargo fetch --manifest-path desktop/src-tauri/Cargo.toml - MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$MESH_REV_SHORT" -type d -name "$MESH_REV_SHORT" | head -1) - if [[ -z "$MESH_ROOT" ]]; then - echo "::error::mesh-llm checkout for $MESH_REV_SHORT not found after cargo fetch" - exit 1 - fi + MESH_ROOT="$(bash scripts/resolve-mesh-llm-checkout.sh "$MESH_REV")" export LLAMA_STAGE_BACKEND=metal export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal" export CMAKE_OSX_DEPLOYMENT_TARGET=10.15 diff --git a/Cargo.lock b/Cargo.lock index 3b60dc4579f..360c2e695bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -879,6 +879,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-azure-storage" +version = "0.1.0" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "object_store", + "thiserror 2.0.18", + "tokio", + "uuid", +] + [[package]] name = "buzz-cli" version = "0.1.0" @@ -996,6 +1009,7 @@ version = "0.1.0" dependencies = [ "axum", "blurhash", + "buzz-azure-storage", "buzz-core", "bytes", "chrono", @@ -1130,6 +1144,7 @@ dependencies = [ "base64", "buzz-audit", "buzz-auth", + "buzz-azure-storage", "buzz-conformance", "buzz-core", "buzz-db", @@ -2174,7 +2189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -4080,6 +4095,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -4220,6 +4244,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kiingo-compute-acp" +version = "0.1.0" +dependencies = [ + "chrono", + "reqwest 0.13.4", + "serde", + "serde_json", + "tokio", + "tokio-util", + "uuid", +] + [[package]] name = "konst" version = "0.4.3" @@ -5782,6 +5819,44 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64", + "bytes", + "chrono", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body-util", + "httparse", + "humantime", + "hyper", + "itertools 0.15.0", + "parking_lot", + "percent-encoding", + "quick-xml 0.41.0", + "rand 0.10.1", + "reqwest 0.13.4", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", + "web-time", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6633,7 +6708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -6652,7 +6727,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6801,6 +6876,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -7027,7 +7112,7 @@ dependencies = [ "compact_str 0.9.1", "critical-section", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "kasuari", "lru 0.18.0", "palette", @@ -7092,7 +7177,7 @@ dependencies = [ "hashbrown 0.17.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -9500,7 +9585,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -10578,7 +10663,7 @@ dependencies = [ "futures-util", "getrandom 0.4.3", "heapify", - "itertools", + "itertools 0.14.0", "lazy_static", "lz4_flex", "more-asserts", @@ -10610,7 +10695,7 @@ dependencies = [ "clap", "gearhash", "http", - "itertools", + "itertools 0.14.0", "lazy_static", "more-asserts", "rand 0.10.1", diff --git a/Cargo.toml b/Cargo.toml index 3ac7ee4cce1..3758098bc0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/buzz-search", "crates/buzz-audit", "crates/buzz-acp", + "crates/kiingo-compute-acp", "crates/buzz-agent", "crates/sprig", "crates/buzz-test-client", @@ -17,6 +18,7 @@ members = [ "crates/buzz-admin", "crates/buzz-workflow", "crates/buzz-media", + "crates/buzz-azure-storage", "crates/buzz-cli", "crates/buzz-pairing-cli", "crates/buzz-sdk", @@ -131,6 +133,7 @@ buzz-search = { path = "crates/buzz-search" } buzz-audit = { path = "crates/buzz-audit" } buzz-workflow = { path = "crates/buzz-workflow" } buzz-media = { path = "crates/buzz-media" } +buzz-azure-storage = { path = "crates/buzz-azure-storage" } buzz-sdk = { path = "crates/buzz-sdk" } buzz-ws-client = { path = "crates/buzz-ws-client" } buzz-relay-mesh = { path = "crates/buzz-relay-mesh" } diff --git a/Dockerfile b/Dockerfile index d883ac6b015..c49fd2c633c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,14 +69,22 @@ RUN cargo chef cook --release --recipe-path recipe.json COPY . . RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \ -p buzz-admin --bin buzz-admin \ - -p buzz-pair-relay --bin buzz-pair-relay + -p buzz-pair-relay --bin buzz-pair-relay \ + -p buzz-acp --bin buzz-acp \ + -p buzz-cli --bin buzz \ + -p buzz-dev-mcp --bin buzz-dev-mcp \ + -p kiingo-compute-acp --bin kiingo-compute-acp # Derive the normal release binaries from the same optimized ELF files as the # debug image so the two variants cannot drift at code-generation time. FROM builder AS stripped-binaries RUN strip target/release/buzz-relay \ && strip target/release/buzz-admin \ - && strip target/release/buzz-pair-relay + && strip target/release/buzz-pair-relay \ + && strip target/release/buzz-acp \ + && strip target/release/buzz \ + && strip target/release/buzz-dev-mcp \ + && strip target/release/kiingo-compute-acp # ─── Stage 4: web bundle (pnpm + vite) ────────────────────────────────────── # Independent of the Rust layers so a CSS change doesn't bust Rust cache and @@ -137,6 +145,7 @@ RUN apt-get update \ curl \ git \ openssl \ + postgresql-client \ && rm -rf /var/lib/apt/lists/* \ && groupadd --system --gid 1000 buzz \ && useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz \ @@ -170,6 +179,32 @@ COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay +# Kiingo production agent listener image. The custom ACP child carries only a +# narrowly scoped bridge credential; `buzz-acp` retains the Nostr signer and +# performs every Buzz write locally. Build with `--target agent-runtime`. +FROM debian:${DEBIAN_VERSION}-slim AS agent-runtime +LABEL org.opencontainers.image.title="Buzz Kiingo Compute Agent" \ + org.opencontainers.image.description="Buzz ACP listener with an exact-user Kiingo Compute adapter" \ + org.opencontainers.image.source="https://github.com/Kiingo/buzz" \ + org.opencontainers.image.licenses="Apache-2.0" +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1000 buzz \ + && useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz \ + --create-home --shell /usr/sbin/nologin buzz +COPY --from=stripped-binaries /build/target/release/buzz-acp /usr/local/bin/buzz-acp +COPY --from=stripped-binaries /build/target/release/buzz-dev-mcp /usr/local/bin/buzz-dev-mcp +COPY --from=stripped-binaries /build/target/release/buzz /usr/local/bin/buzz +COPY --from=stripped-binaries /build/target/release/kiingo-compute-acp /usr/local/bin/kiingo-compute-acp +ENV BUZZ_ACP_AGENT_COMMAND=/usr/local/bin/kiingo-compute-acp \ + BUZZ_ACP_AGENT_ARGS="" \ + BUZZ_ACP_MCP_COMMAND=/usr/local/bin/buzz-dev-mcp \ + BUZZ_ACP_KIINGO_PUBLICATION_ENABLED=true +USER buzz:buzz +WORKDIR /var/lib/buzz +ENTRYPOINT ["/usr/local/bin/buzz-acp"] + # Keep the stripped runtime as the final/default Dockerfile target so existing # `docker build .` callers and release tags retain their current behavior. FROM runtime-base AS runtime diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..09d3d817a4b 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -110,6 +110,7 @@ All configuration is via environment variables (or CLI flags — every env var h | `BUZZ_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. | | `BUZZ_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. | | `BUZZ_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). | +| `BUZZ_ACP_KIINGO_PUBLICATION_ENABLED` | no | `false` | Enables the fenced, locally signed Kiingo publication extension. Use only with `kiingo-compute-acp`. | | `BUZZ_ACP_MCP_COMMAND` | no | `""` (empty) | Path to an optional MCP server binary to provide to the agent subprocess. | | `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). | diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a03..312f22e7eef 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -13,6 +13,7 @@ use tokio::io::AsyncWriteExt; use tokio::process::{Child, ChildStdin, ChildStdout}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; +use crate::kiingo_publication::{KiingoPublicationIntent, LocalPublicationPublisher}; use crate::observer::{ObserverContext, ObserverHandle}; use crate::usage::{TurnUsage, UsageTracker}; @@ -20,6 +21,12 @@ 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 +fn is_kiingo_publication_update(msg: &serde_json::Value) -> bool { + msg.pointer("/params/update/sessionUpdate") + .and_then(|value| value.as_str()) + == Some("kiingo_buzz_publication") +} + /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. @@ -211,6 +218,14 @@ pub struct AcpClient { /// deltas. Both goose and buzz-agent emit this notification; goose gates /// on client capability advertisement, buzz-agent emits unconditionally. goose_usage: UsageTracker, + /// Optional, explicitly enabled publisher for the Kiingo ACP extension. + /// It holds the Buzz signer in this parent process; the child only emits + /// validated publication intents and never receives private key material. + kiingo_publication_publisher: Option, + /// Structured Buzz envelope attached to the next `session/prompt` as a + /// namespaced ACP extension. Custom agents can consume this instead of + /// parsing the human-readable prompt; legacy agents ignore it. + buzz_prompt_metadata: Option, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -550,9 +565,25 @@ impl AcpClient { steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), + kiingo_publication_publisher: None, + buzz_prompt_metadata: None, }) } + /// Install (or clear) the local Kiingo publication boundary for this + /// process. Production enables it explicitly with + /// `BUZZ_ACP_KIINGO_PUBLICATION_ENABLED=true`. + pub(crate) fn set_kiingo_publication_publisher( + &mut self, + publisher: Option, + ) { + self.kiingo_publication_publisher = publisher; + } + + pub(crate) fn set_buzz_prompt_metadata(&mut self, metadata: Option) { + self.buzz_prompt_metadata = metadata; + } + /// Attach a local observer feed to this ACP client. pub fn set_observer(&mut self, observer: Option, agent_index: usize) { self.observer = observer; @@ -586,6 +617,36 @@ impl AcpClient { } } + /// The publication extension carries user-authored output to a strictly + /// local signer. Keep that body out of debug wire logs and observer frames; + /// correlation identifiers and byte length remain observable. + fn observe_acp_read(&self, msg: &serde_json::Value) { + if is_kiingo_publication_update(msg) { + let mut redacted = msg.clone(); + if let Some(update) = redacted.pointer_mut("/params/update") { + let content_bytes = update + .get("content") + .and_then(|value| value.as_str()) + .map(str::len) + .unwrap_or(0); + update["content"] = serde_json::json!("[redacted local publication content]"); + update["contentBytes"] = serde_json::json!(content_bytes); + } + tracing::debug!( + target: "acp::wire", + "received redacted kiingo_buzz_publication update" + ); + self.observe("acp_read", redacted); + return; + } + tracing::debug!( + target: "acp::wire", + "← {}", + serde_json::to_string(msg).unwrap_or_default() + ); + self.observe("acp_read", msg.clone()); + } + /// Send the `initialize` request and return the agent's response result value. /// /// Must be called exactly once, before any other ACP method. @@ -751,7 +812,8 @@ impl AcpClient { idle_timeout: std::time::Duration, max_duration: std::time::Duration, ) -> Result { - let params = build_prompt_params(session_id, prompt_blocks); + let params = + build_prompt_params(session_id, prompt_blocks, self.buzz_prompt_metadata.take()); let hard_deadline = tokio::time::Instant::now() + max_duration; self.current_hard_deadline = Some(hard_deadline); @@ -1177,9 +1239,6 @@ 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) => { @@ -1197,7 +1256,7 @@ impl AcpClient { continue; } }; - self.observe("acp_read", msg.clone()); + self.observe_acp_read(&msg); // 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 @@ -1501,8 +1560,6 @@ 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) => { @@ -1520,7 +1577,7 @@ impl AcpClient { continue; } }; - self.observe("acp_read", msg.clone()); + self.observe_acp_read(&msg); let activity_now = Instant::now(); idle_deadline = activity_now + idle_timeout; @@ -1712,6 +1769,24 @@ impl AcpClient { .unwrap_or("unknown"); match update_type { + "kiingo_buzz_publication" => { + let Some(publisher) = &self.kiingo_publication_publisher else { + tracing::warn!( + target: "kiingo::publication", + "discarded Kiingo publication update because the local publisher is disabled" + ); + return false; + }; + match serde_json::from_value::(update.clone()) { + Ok(intent) => publisher.enqueue(intent), + Err(error) => tracing::warn!( + target: "kiingo::publication", + error = %error, + "discarded malformed Kiingo publication update" + ), + } + false + } "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { tracing::info!(target: "acp::stream", "{text}"); @@ -1950,15 +2025,23 @@ impl AcpClient { } /// Build `session/prompt` params from one or more text content blocks. -fn build_prompt_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::Value { +fn build_prompt_params( + session_id: &str, + prompt_blocks: &[&str], + buzz_metadata: Option, +) -> serde_json::Value { let blocks: Vec = prompt_blocks .iter() .map(|text| serde_json::json!({ "type": "text", "text": text })) .collect(); - serde_json::json!({ + let mut params = serde_json::json!({ "sessionId": session_id, "prompt": blocks, - }) + }); + if let Some(metadata) = buzz_metadata { + params["_meta"] = serde_json::json!({ "buzz": metadata }); + } + params } /// Build `_goose/unstable/session/steer` params from one or more text @@ -2456,6 +2539,7 @@ mod tests { "/goal ship it", "[Buzz event: @mention]\nContent: @Eva /goal ship it", ], + None, ); let prompt = params["prompt"].as_array().unwrap(); assert_eq!(prompt.len(), 2); diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index c42e65cb832..e360d249826 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -40,6 +40,8 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat - Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently. - Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery. +- When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. +- Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. - Only `@mention` when you need their attention. Don't mention in narrative (e.g., "coordinating with Duncan" — no `@`). Naming someone while talking *about* them is narrative — "waiting on @morgan", "until @morgan brings work", "I'll loop in @morgan later". Drop the `@`. Every mention sends a notification; a mention nobody needs to act on is a false alarm. ### Callback Mentions diff --git a/crates/buzz-acp/src/kiingo_publication.rs b/crates/buzz-acp/src/kiingo_publication.rs new file mode 100644 index 00000000000..2f21c0c71e1 --- /dev/null +++ b/crates/buzz-acp/src/kiingo_publication.rs @@ -0,0 +1,303 @@ +//! Local Buzz publication boundary for the Kiingo Compute ACP adapter. +//! +//! The remote compute process never receives the Buzz agent's private key. +//! Instead, `kiingo-compute-acp` emits a structured ACP update after it has +//! acquired a server-side publication fence. `buzz-acp` validates that update, +//! signs the message locally, submits it through the normal relay REST path, +//! and reports the resulting Nostr event id back to Kiingo. + +use std::time::Duration; + +use nostr::{Alphabet, Filter, Kind, SingleLetterTag}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::relay::RestClient; + +const COMPLETE_TIMEOUT: Duration = Duration::from_secs(5); +const RELAY_LOOKUP_TIMEOUT: Duration = Duration::from_secs(3); +const COMPLETE_RETRY_DELAYS: [Duration; 4] = [ + Duration::from_millis(100), + Duration::from_millis(250), + Duration::from_millis(500), + Duration::from_secs(1), +]; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct KiingoPublicationIntent { + #[serde(rename = "sessionUpdate")] + pub session_update: String, + pub community_id: String, + pub agent_public_key: String, + pub receipt_id: String, + pub fence_id: String, + pub channel_id: String, + pub thread_root_event_id: Option, + pub reply_to_event_id: String, + pub publication_kind: String, + pub content: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct LocalPublicationPublisher { + rest: RestClient, + kiingo_api_base_url: String, + internal_token: String, +} + +impl LocalPublicationPublisher { + pub(crate) fn from_env(rest: RestClient) -> Option { + if !matches!( + std::env::var("BUZZ_ACP_KIINGO_PUBLICATION_ENABLED") + .ok() + .as_deref(), + Some("1" | "true" | "TRUE") + ) { + return None; + } + let kiingo_api_base_url = std::env::var("KIINGO_API_BASE_URL") + .ok()? + .trim() + .trim_end_matches('/') + .to_string(); + if !(kiingo_api_base_url.starts_with("https://") + || kiingo_api_base_url.starts_with("http://127.0.0.1") + || kiingo_api_base_url.starts_with("http://localhost")) + { + tracing::error!( + target: "kiingo::publication", + "KIINGO_API_BASE_URL must use HTTPS (loopback HTTP is allowed for tests)" + ); + return None; + } + let internal_token = std::env::var("BUZZ_BRIDGE_INTERNAL_TOKEN").ok()?; + if internal_token.trim().is_empty() { + return None; + } + Some(Self { + rest, + kiingo_api_base_url, + internal_token, + }) + } + + pub(crate) fn enqueue(&self, intent: KiingoPublicationIntent) { + let publisher = self.clone(); + tokio::spawn(async move { + if let Err(error) = publisher.publish(intent).await { + tracing::error!(target: "kiingo::publication", error = %error, "local Buzz publication failed"); + } + }); + } + + async fn publish(&self, intent: KiingoPublicationIntent) -> Result<(), String> { + validate_intent(&intent, &self.rest)?; + let fence_tag_value = format!("kiingo-publication:{}", intent.fence_id); + if let Some(event_id) = self.find_existing_event(&fence_tag_value).await? { + self.complete_fence(&intent, &event_id).await?; + tracing::info!( + target: "kiingo::publication", + receipt_id = %intent.receipt_id, + fence_id = %intent.fence_id, + buzz_event_id = %event_id, + "reconciled an already-published Buzz event" + ); + return Ok(()); + } + + let channel_id = Uuid::parse_str(&intent.channel_id) + .map_err(|_| "publication channel_id is not a UUID".to_string())?; + let root_hex = intent + .thread_root_event_id + .as_deref() + .unwrap_or(&intent.reply_to_event_id); + let root = nostr::EventId::from_hex(root_hex) + .map_err(|_| "publication thread root event id is invalid".to_string())?; + let thread_ref = buzz_sdk::ThreadRef { + root_event_id: root, + // Human-facing Kiingo replies remain flat under the root. + parent_event_id: root, + }; + let builder = buzz_sdk::build_message_with_extra_tags( + channel_id, + &intent.content, + Some(&thread_ref), + &[], + false, + &[], + &[vec!["d".to_string(), fence_tag_value]], + ) + .map_err(|error| format!("publication build failed: {error}"))?; + let event = builder + .sign_with_keys(&self.rest.keys) + .map_err(|error| format!("publication signing failed: {error}"))?; + let event_id = event.id.to_hex(); + tokio::time::timeout(Duration::from_secs(5), self.rest.submit_event(&event)) + .await + .map_err(|_| "publication relay submission timed out".to_string())? + .map_err(|error| format!("publication relay submission failed: {error}"))?; + self.complete_fence(&intent, &event_id).await?; + tracing::info!( + target: "kiingo::publication", + receipt_id = %intent.receipt_id, + fence_id = %intent.fence_id, + publication_kind = %intent.publication_kind, + buzz_event_id = %event_id, + "published locally signed Kiingo output" + ); + Ok(()) + } + + async fn find_existing_event(&self, fence_tag_value: &str) -> Result, String> { + let filter = Filter::new() + .kind(Kind::Custom(9)) + .author(self.rest.keys.public_key()) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [fence_tag_value]) + .limit(1); + let response = tokio::time::timeout(RELAY_LOOKUP_TIMEOUT, self.rest.query(&[filter])) + .await + .map_err(|_| "publication reconciliation query timed out".to_string())? + .map_err(|error| format!("publication reconciliation query failed: {error}"))?; + Ok(response + .as_array() + .and_then(|events| events.first()) + .and_then(|event| event.get("id")) + .and_then(|id| id.as_str()) + .map(str::to_string)) + } + + async fn complete_fence( + &self, + intent: &KiingoPublicationIntent, + event_id: &str, + ) -> Result<(), String> { + let url = format!( + "{}/api/buzz-bridge/publications/{}/complete", + self.kiingo_api_base_url, intent.fence_id + ); + let body = serde_json::json!({ + "receipt_id": intent.receipt_id, + "community_id": intent.community_id, + "agent_public_key": intent.agent_public_key, + "buzz_event_id": event_id, + }); + let mut last_error = "publication fence completion failed".to_string(); + for attempt in 0..=COMPLETE_RETRY_DELAYS.len() { + let result = tokio::time::timeout( + COMPLETE_TIMEOUT, + self.rest + .http + .post(&url) + .header("x-kiingo-internal-token", &self.internal_token) + .json(&body) + .send(), + ) + .await; + match result { + Ok(Ok(response)) if response.status().is_success() => return Ok(()), + Ok(Ok(response)) => { + last_error = format!( + "publication fence completion returned HTTP {}", + response.status().as_u16() + ); + } + Ok(Err(error)) => { + last_error = format!("publication fence completion failed: {error}") + } + Err(_) => last_error = "publication fence completion timed out".to_string(), + } + if let Some(delay) = COMPLETE_RETRY_DELAYS.get(attempt) { + tokio::time::sleep(*delay).await; + } + } + Err(last_error) + } +} + +fn validate_intent(intent: &KiingoPublicationIntent, rest: &RestClient) -> Result<(), String> { + if intent.session_update != "kiingo_buzz_publication" { + return Err("publication ACP update discriminator is invalid".to_string()); + } + let agent_public_key = intent.agent_public_key.trim().to_ascii_lowercase(); + if agent_public_key != rest.keys.public_key().to_hex() { + return Err("publication agent key does not match the local signer".to_string()); + } + if intent.community_id.trim().is_empty() + || intent.receipt_id.trim().is_empty() + || intent.fence_id.trim().is_empty() + || intent.reply_to_event_id.len() != 64 + || !intent + .reply_to_event_id + .chars() + .all(|character| character.is_ascii_hexdigit()) + || intent.content.trim().is_empty() + || intent.content.len() > 64 * 1024 + { + return Err("publication intent failed local validation".to_string()); + } + if !matches!( + intent.publication_kind.as_str(), + "receipt" | "progress" | "capacity" | "final" | "error" | "cancelled" + ) { + return Err("publication kind is not allowed".to_string()); + } + if let Some(root) = intent.thread_root_event_id.as_deref() { + if root.len() != 64 || !root.chars().all(|character| character.is_ascii_hexdigit()) { + return Err("publication thread root event id is invalid".to_string()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + + fn rest(keys: Keys) -> RestClient { + RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:3000".to_string(), + keys, + auth_tag_json: None, + } + } + + fn intent(agent_public_key: String) -> KiingoPublicationIntent { + KiingoPublicationIntent { + session_update: "kiingo_buzz_publication".to_string(), + community_id: "kiingo".to_string(), + agent_public_key, + receipt_id: Uuid::new_v4().to_string(), + fence_id: Uuid::new_v4().to_string(), + channel_id: Uuid::new_v4().to_string(), + thread_root_event_id: None, + reply_to_event_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + publication_kind: "final".to_string(), + content: "Done".to_string(), + } + } + + #[test] + fn accepts_intent_only_for_the_local_signer() { + let keys = Keys::generate(); + let rest = rest(keys.clone()); + assert!(validate_intent(&intent(keys.public_key().to_hex()), &rest).is_ok()); + let other = Keys::generate(); + assert!(validate_intent(&intent(other.public_key().to_hex()), &rest).is_err()); + } + + #[test] + fn rejects_unknown_publication_fields_and_kinds() { + let keys = Keys::generate(); + let mut value = serde_json::to_value(intent(keys.public_key().to_hex())).unwrap(); + value["private_key"] = serde_json::json!("must-not-cross-boundary"); + assert!(serde_json::from_value::(value).is_err()); + + let mut invalid = intent(keys.public_key().to_hex()); + invalid.publication_kind = "arbitrary_write".to_string(); + assert!(validate_intent(&invalid, &rest(keys)).is_err()); + } +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d63f720c651..c39bc9064f0 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4,6 +4,7 @@ mod acp; mod config; mod engram_fetch; mod filter; +mod kiingo_publication; mod observer; mod pool; mod pool_lifecycle; @@ -3625,6 +3626,22 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("single-quoted shell strings preserve `\\n` literally")); assert!(prompt.contains("buzz messages send ... --content -")); } + + #[test] + fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("--mention ")); + assert!(prompt.contains("every presentation-only name that should notify")); + assert!( + prompt.contains("permits unresolved or ambiguous `@Name` text as presentation-only") + ); + assert!(prompt.contains("success JSON's `mention_pubkeys`")); + assert!(prompt.contains("no follow-up verification command is needed")); + assert!(prompt.contains("stops before sending")); + assert!(prompt + .contains("add them explicitly with `buzz channels add-member` only when authorized")); + assert!(prompt.contains("never changes membership automatically")); + } } fn default_heartbeat_prompt() -> String { diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 038f8a714c1..a244b386115 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1358,6 +1358,9 @@ pub async fn run_prompt_task( turn_id.clone(), turn_started_at.clone(), )); + agent.acp.set_kiingo_publication_publisher( + crate::kiingo_publication::LocalPublicationPublisher::from_env(ctx.rest_client.clone()), + ); let triggering_event_ids: Vec = batch .as_ref() .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) @@ -1803,6 +1806,7 @@ pub async fn run_prompt_task( // follows as a second block. let mut slash_command: Option = None; let prompt_sections: Vec = if let Some(text) = prompt_text { + agent.acp.set_buzz_prompt_metadata(None); // Heartbeats create their session before this point, so a Goose method-not-found // probe has already selected the correct framing for this process. let text = prepend_base_for_legacy( @@ -1820,6 +1824,34 @@ pub async fn run_prompt_task( // Try startup cache first; lazy-fetch via REST for dynamic channels. let channel_info = ctx.channel_info.resolve(b.channel_id).await; + if let Some(trigger) = b.events.last() { + let thread = crate::queue::parse_thread_tags(&trigger.event); + let tags: Vec<&[String]> = trigger + .event + .tags + .iter() + .map(|tag| tag.as_slice()) + .collect(); + agent.acp.set_buzz_prompt_metadata(Some(serde_json::json!({ + "contractVersion": 1, + "eventId": trigger.event.id.to_hex(), + "channelId": b.channel_id.to_string(), + "channelName": channel_info.as_ref().map(|info| info.name.as_str()), + "kind": trigger.event.kind.as_u16() as u32, + "authorPublicKey": trigger.event.pubkey.to_hex(), + "authoredAt": chrono::DateTime::from_timestamp( + trigger.event.created_at.as_secs() as i64, + 0, + ).map(|time| time.to_rfc3339()), + "text": trigger.event.content, + "tags": tags, + "threadRootEventId": thread.root_event_id, + "replyToEventId": trigger.event.id.to_hex(), + }))); + } else { + agent.acp.set_buzz_prompt_metadata(None); + } + let conversation_context = if ctx.context_message_limit > 0 { fetch_conversation_context(b, &channel_info, &ctx).await } else { diff --git a/crates/buzz-azure-storage/Cargo.toml b/crates/buzz-azure-storage/Cargo.toml new file mode 100644 index 00000000000..bc0b260dec2 --- /dev/null +++ b/crates/buzz-azure-storage/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "buzz-azure-storage" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Azure Blob Storage adapter and conformance surface for Buzz" + +[dependencies] +bytes = "1" +futures-core = "0.3" +futures-util = "0.3" +object_store = { version = "0.14.1", default-features = false, features = ["azure", "tokio"] } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs"] } + +[dev-dependencies] +uuid = { workspace = true } diff --git a/crates/buzz-azure-storage/README.md b/crates/buzz-azure-storage/README.md new file mode 100644 index 00000000000..f0a565c4aeb --- /dev/null +++ b/crates/buzz-azure-storage/README.md @@ -0,0 +1,49 @@ +# Buzz Azure Storage Adapter + +This crate is the Azure Blob Storage proof for Buzz's media and git storage +contracts. It intentionally keeps Azure-specific code outside the current S3 +paths until the backend passes the required concurrency semantics. + +The conformance test covers: + +- atomic create-only writes (`If-None-Match: *`), +- ETag compare-and-swap updates (`If-Match`), +- one winner under concurrent create and update races, +- GET body and ETag consistency, +- range reads, streaming reads, HEAD, paginated listing, and idempotent delete, +- bounded multipart file upload with a range-verified large object. + +Production clients use Azure's credential environment. On AKS, configure +workload identity with `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and +`AZURE_FEDERATED_TOKEN_FILE`; no storage account key is required. + +## Local validation + +Run Azurite's blob service on port 10000, create a container named +`buzz-conformance`, and then run: + +```shell +BUZZ_AZURITE_TEST=1 cargo test -p buzz-azure-storage --test azurite_conformance +``` + +## Private Azure validation + +Run the same test from an AKS workload-identity Pod that can resolve the private +Blob endpoint. Scope `Storage Blob Data Contributor` to only the disposable +conformance container, then set: + +```shell +BUZZ_AZURE_TEST=1 \ +BUZZ_AZURE_STORAGE_ACCOUNT= \ +BUZZ_AZURE_CONFORMANCE_CONTAINER=buzz-conformance \ +cargo test -p buzz-azure-storage --test azurite_conformance +``` + +The test uses no account key, writes under a unique `probe/` prefix, and +deletes that prefix after a successful run. Version restore and soft-delete +recovery are control-plane validations and remain separate from this data-plane +adapter contract. + +Azurite is test-only. Production should use a dedicated Buzz storage account, +private endpoint, private DNS zone, workload identity, soft delete, versioning, +and a lifecycle policy. diff --git a/crates/buzz-azure-storage/src/lib.rs b/crates/buzz-azure-storage/src/lib.rs new file mode 100644 index 00000000000..356a1cd2a36 --- /dev/null +++ b/crates/buzz-azure-storage/src/lib.rs @@ -0,0 +1,400 @@ +//! Azure Blob Storage primitives required by Buzz media and git storage. +//! +//! The adapter deliberately exposes conditional writes as a semantic outcome: +//! losing an optimistic-concurrency race is expected, not a transport error. +//! Production construction uses the Azure credential environment, which lets +//! AKS workload identity provide short-lived credentials without storage keys. + +#![deny(unsafe_code)] + +use std::ops::Range; +use std::path::Path as FilePath; +use std::pin::Pin; +use std::sync::Arc; + +use bytes::Bytes; +use futures_core::Stream; +use futures_util::TryStreamExt; +use object_store::azure::{MicrosoftAzure, MicrosoftAzureBuilder}; +use object_store::list::{PaginatedListOptions, PaginatedListStore}; +use object_store::path::Path; +use object_store::{ + Attribute, Attributes, Error as ObjectStoreError, ObjectMeta, ObjectStore, ObjectStoreExt, + PutMode, PutMultipartOptions, PutOptions, PutResult, UpdateVersion, WriteMultipart, +}; +use tokio::io::AsyncReadExt; + +/// A streaming Azure Blob response suitable for an HTTP response body. +pub type ByteStream = + Pin> + Send + 'static>>; + +/// A blob body and the exact version metadata observed by the same GET. +#[derive(Debug)] +pub struct VersionedObject { + /// Object bytes. + pub bytes: Bytes, + /// Version to supply to a subsequent compare-and-swap write. + pub version: BlobVersion, + /// Object attributes returned by Azure, including content type when set. + pub attributes: Attributes, +} + +/// Result of an atomic conditional write. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConditionalWrite { + /// The write committed and returned the new object version. + Won(BlobVersion), + /// Another writer won the precondition race. + LostRace, +} + +/// Opaque Azure object version suitable for a later compare-and-swap write. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlobVersion { + /// Strong ETag returned by the same read or successful write. + pub etag: String, + /// Optional Azure version identifier when account versioning is enabled. + pub version: Option, +} + +/// Backend-neutral object metadata used by Buzz media and sweep paths. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlobObjectMetadata { + /// Full object key within the configured container. + pub key: String, + /// Object size in bytes. + pub size: u64, + /// Strong ETag when returned by Azure. + pub etag: Option, +} + +/// One bounded listing page and an opaque continuation token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlobListPage { + /// Objects returned in this page. + pub objects: Vec, + /// Token to pass to the next request, or `None` for the final page. + pub continuation_token: Option, +} + +/// Azure Blob adapter failures. +#[derive(Debug, thiserror::Error)] +pub enum AzureStorageError { + /// Object key could not be represented as an Azure blob path. + #[error("invalid Azure Blob Storage object key: {0}")] + InvalidPath(#[from] object_store::path::Error), + /// Azure or transport failure. + #[error("Azure Blob Storage error: {0}")] + Backend(#[from] ObjectStoreError), + /// A successful write or read omitted the ETag needed for Buzz CAS. + #[error("Azure Blob Storage response for '{key}' did not include an ETag")] + MissingEtag { + /// Object key whose response was incomplete. + key: String, + }, +} + +impl AzureStorageError { + /// Whether Azure reported that the requested object does not exist. + pub fn is_not_found(&self) -> bool { + matches!(self, Self::Backend(ObjectStoreError::NotFound { .. })) + } +} + +/// Azure Blob Storage implementation of the object operations Buzz requires. +#[derive(Clone, Debug)] +pub struct AzureBlobStore { + inner: Arc, +} + +impl AzureBlobStore { + /// Build a production client from the Azure credential environment. + /// + /// In AKS, set `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and + /// `AZURE_FEDERATED_TOKEN_FILE`; `object_store` will use workload identity. + /// A managed identity is used when no more-specific credential is present. + pub fn from_env(account: &str, container: &str) -> Result { + let inner = MicrosoftAzureBuilder::from_env() + .with_account(account) + .with_container_name(container) + .build()?; + Ok(Self { + inner: Arc::new(inner), + }) + } + + /// Build a client for the local Azurite emulator. + pub fn for_azurite(container: &str) -> Result { + let inner = MicrosoftAzureBuilder::new() + .with_container_name(container) + .with_use_emulator(true) + .build()?; + Ok(Self { + inner: Arc::new(inner), + }) + } + + /// Atomically create an object only when its key is absent. + pub async fn create( + &self, + key: &str, + bytes: Bytes, + content_type: &str, + ) -> Result { + self.conditional_put(key, bytes, content_type, PutMode::Create) + .await + } + + /// Atomically replace an object only when its version still matches. + pub async fn update( + &self, + key: &str, + bytes: Bytes, + content_type: &str, + version: BlobVersion, + ) -> Result { + self.conditional_put(key, bytes, content_type, PutMode::Update(version.into())) + .await + } + + /// Put an object, replacing an existing value when present. + pub async fn put( + &self, + key: &str, + bytes: Bytes, + content_type: &str, + ) -> Result { + let path = object_path(key)?; + let result = self + .inner + .put_opts( + &path, + bytes.into(), + put_options(content_type, PutMode::Overwrite), + ) + .await?; + require_etag(key, result) + } + + /// Stream a file to Azure using bounded multipart buffering. + pub async fn put_file( + &self, + key: &str, + file_path: &FilePath, + content_type: &str, + ) -> Result { + const READ_BUFFER_BYTES: usize = 1024 * 1024; + const UPLOAD_CHUNK_BYTES: usize = 8 * 1024 * 1024; + const MAX_IN_FLIGHT_PARTS: usize = 2; + + let path = object_path(key)?; + let mut attributes = Attributes::new(); + attributes.insert(Attribute::ContentType, content_type.to_string().into()); + let upload = self + .inner + .put_multipart_opts( + &path, + PutMultipartOptions { + attributes, + ..Default::default() + }, + ) + .await?; + let mut writer = WriteMultipart::new_with_chunk_size(upload, UPLOAD_CHUNK_BYTES); + let mut file = + tokio::fs::File::open(file_path) + .await + .map_err(|source| ObjectStoreError::Generic { + store: "MicrosoftAzure", + source: Box::new(source), + })?; + let mut buffer = vec![0_u8; READ_BUFFER_BYTES]; + loop { + let read = + file.read(&mut buffer) + .await + .map_err(|source| ObjectStoreError::Generic { + store: "MicrosoftAzure", + source: Box::new(source), + })?; + if read == 0 { + break; + } + writer.wait_for_capacity(MAX_IN_FLIGHT_PARTS).await?; + writer.write(&buffer[..read]); + } + let result = writer.finish().await?; + require_etag(key, result) + } + + /// Read an object's bytes and CAS version from one GET response. + pub async fn get(&self, key: &str) -> Result { + let path = object_path(key)?; + let result = self.inner.get(&path).await?; + let version = version_from_meta(key, &result.meta)?; + let attributes = result.attributes.clone(); + let bytes = result.bytes().await?; + Ok(VersionedObject { + bytes, + version, + attributes, + }) + } + + /// Stream an object's bytes without buffering the full body. + pub async fn get_stream(&self, key: &str) -> Result { + let path = object_path(key)?; + let result = self.inner.get(&path).await?; + Ok(Box::pin( + result.into_stream().map_err(AzureStorageError::from), + )) + } + + /// Read a half-open byte range from an object. + pub async fn get_range( + &self, + key: &str, + range: Range, + ) -> Result { + let path = object_path(key)?; + Ok(self.inner.get_range(&path, range).await?) + } + + /// Return object metadata, or `None` when the key is absent. + pub async fn head(&self, key: &str) -> Result, AzureStorageError> { + let path = object_path(key)?; + match self.inner.head(&path).await { + Ok(meta) => Ok(Some(metadata(meta))), + Err(ObjectStoreError::NotFound { .. }) => Ok(None), + Err(error) => Err(error.into()), + } + } + + /// Delete an object. Azure treats deleting an absent blob as not found. + pub async fn delete(&self, key: &str) -> Result<(), AzureStorageError> { + let path = object_path(key)?; + self.inner.delete(&path).await?; + Ok(()) + } + + /// Delete an object while treating an absent key as idempotent success. + pub async fn delete_if_exists(&self, key: &str) -> Result<(), AzureStorageError> { + match self.delete(key).await { + Ok(()) | Err(AzureStorageError::Backend(ObjectStoreError::NotFound { .. })) => Ok(()), + Err(error) => Err(error), + } + } + + /// List all objects under a prefix, following Azure continuation pages. + pub async fn list_prefix( + &self, + prefix: &str, + ) -> Result, AzureStorageError> { + let path = object_path(prefix)?; + Ok(self + .inner + .list(Some(&path)) + .map_ok(metadata) + .try_collect::>() + .await?) + } + + /// List one bounded Azure page using Azure's native continuation token. + pub async fn list_page( + &self, + prefix: Option<&str>, + continuation_token: Option, + max_keys: usize, + ) -> Result { + let page = self + .inner + .list_paginated( + prefix, + PaginatedListOptions { + max_keys: Some(max_keys), + page_token: continuation_token, + ..Default::default() + }, + ) + .await?; + Ok(BlobListPage { + objects: page.result.objects.into_iter().map(metadata).collect(), + continuation_token: page.page_token, + }) + } + + async fn conditional_put( + &self, + key: &str, + bytes: Bytes, + content_type: &str, + mode: PutMode, + ) -> Result { + let path = object_path(key)?; + match self + .inner + .put_opts(&path, bytes.into(), put_options(content_type, mode)) + .await + { + Ok(result) => Ok(ConditionalWrite::Won(require_etag(key, result)?)), + Err(ObjectStoreError::AlreadyExists { .. } | ObjectStoreError::Precondition { .. }) => { + Ok(ConditionalWrite::LostRace) + } + Err(error) => Err(error.into()), + } + } +} + +fn object_path(key: &str) -> Result { + Ok(Path::parse(key)?) +} + +fn put_options(content_type: &str, mode: PutMode) -> PutOptions { + let mut attributes = Attributes::new(); + attributes.insert(Attribute::ContentType, content_type.to_string().into()); + PutOptions { + mode, + attributes, + ..Default::default() + } +} + +fn require_etag(key: &str, result: PutResult) -> Result { + let etag = result.e_tag.ok_or_else(|| AzureStorageError::MissingEtag { + key: key.to_string(), + })?; + Ok(BlobVersion { + etag, + version: result.version, + }) +} + +fn version_from_meta(key: &str, meta: &ObjectMeta) -> Result { + let etag = meta + .e_tag + .clone() + .ok_or_else(|| AzureStorageError::MissingEtag { + key: key.to_string(), + })?; + Ok(BlobVersion { + etag, + version: meta.version.clone(), + }) +} + +fn metadata(meta: ObjectMeta) -> BlobObjectMetadata { + BlobObjectMetadata { + key: meta.location.to_string(), + size: meta.size, + etag: meta.e_tag, + } +} + +impl From for UpdateVersion { + fn from(value: BlobVersion) -> Self { + Self { + e_tag: Some(value.etag), + version: value.version, + } + } +} diff --git a/crates/buzz-azure-storage/tests/azurite_conformance.rs b/crates/buzz-azure-storage/tests/azurite_conformance.rs new file mode 100644 index 00000000000..fb6f4cf7bb0 --- /dev/null +++ b/crates/buzz-azure-storage/tests/azurite_conformance.rs @@ -0,0 +1,312 @@ +//! Conformance test for the Azure primitives required by Buzz. +//! +//! Run against either a local Azurite blob service or the production-shaped +//! private Azure account through workload identity: +//! +//! ```text +//! BUZZ_AZURITE_TEST=1 cargo test -p buzz-azure-storage --test azurite_conformance +//! BUZZ_AZURE_TEST=1 \ +//! BUZZ_AZURE_STORAGE_ACCOUNT= \ +//! BUZZ_AZURE_CONFORMANCE_CONTAINER=buzz-conformance \ +//! cargo test -p buzz-azure-storage --test azurite_conformance +//! ``` + +use std::sync::Arc; + +use buzz_azure_storage::{AzureBlobStore, ConditionalWrite}; +use bytes::Bytes; +use futures_util::TryStreamExt; +use tokio::sync::Barrier; +use uuid::Uuid; + +const CONTAINER: &str = "buzz-conformance"; +const RACE_WIDTH: usize = 16; + +fn enabled() -> bool { + std::env::var("BUZZ_AZURITE_TEST").as_deref() == Ok("1") + || std::env::var("BUZZ_AZURE_TEST").as_deref() == Ok("1") +} + +fn configured_store() -> AzureBlobStore { + if std::env::var("BUZZ_AZURE_TEST").as_deref() == Ok("1") { + let account = std::env::var("BUZZ_AZURE_STORAGE_ACCOUNT") + .expect("BUZZ_AZURE_STORAGE_ACCOUNT is required for a real-Azure test"); + let container = std::env::var("BUZZ_AZURE_CONFORMANCE_CONTAINER") + .expect("BUZZ_AZURE_CONFORMANCE_CONTAINER is required for a real-Azure test"); + return AzureBlobStore::from_env(&account, &container) + .expect("build workload-identity Azure client"); + } + + AzureBlobStore::for_azurite(CONTAINER).expect("build Azurite client") +} + +#[tokio::test] +async fn azure_blob_satisfies_buzz_storage_contract() { + if !enabled() { + eprintln!("skipping: enable either BUZZ_AZURITE_TEST or BUZZ_AZURE_TEST"); + return; + } + + let store = configured_store(); + let prefix = format!("probe/{}", Uuid::new_v4()); + + sequential_roundtrip(&store, &prefix).await; + create_only_race(&store, &prefix).await; + compare_and_swap_race(&store, &prefix).await; + media_primitives(&store, &prefix).await; + multipart_file_roundtrip(&store, &prefix).await; + cleanup(&store, &prefix).await; +} + +async fn sequential_roundtrip(store: &AzureBlobStore, prefix: &str) { + let key = format!("{prefix}/sequential"); + let created = store + .create(&key, Bytes::from_static(b"v1"), "text/plain") + .await + .expect("create should complete"); + let ConditionalWrite::Won(created_version) = created else { + panic!("unique create unexpectedly lost its race"); + }; + + let read = store.get(&key).await.expect("read created object"); + assert_eq!(read.bytes, Bytes::from_static(b"v1")); + assert_eq!(read.version, created_version); + + let updated = store + .update(&key, Bytes::from_static(b"v2"), "text/plain", read.version) + .await + .expect("update should complete"); + let ConditionalWrite::Won(updated_version) = updated else { + panic!("uncontended update unexpectedly lost its race"); + }; + assert_ne!(updated_version, created_version); + + let read = store.get(&key).await.expect("read updated object"); + assert_eq!(read.bytes, Bytes::from_static(b"v2")); + assert_eq!(read.version, updated_version); +} + +async fn create_only_race(store: &AzureBlobStore, prefix: &str) { + let key = format!("{prefix}/create-race"); + let barrier = Arc::new(Barrier::new(RACE_WIDTH)); + let mut racers = Vec::with_capacity(RACE_WIDTH); + + for index in 0..RACE_WIDTH { + let store = store.clone(); + let key = key.clone(); + let barrier = Arc::clone(&barrier); + racers.push(tokio::spawn(async move { + barrier.wait().await; + store + .create( + &key, + Bytes::from(format!("candidate-{index}")), + "text/plain", + ) + .await + })); + } + + let mut winners = 0; + let mut losers = 0; + for racer in racers { + match racer + .await + .expect("racer task should join") + .expect("Azure response") + { + ConditionalWrite::Won(_) => winners += 1, + ConditionalWrite::LostRace => losers += 1, + } + } + + assert_eq!(winners, 1, "If-None-Match race must have one winner"); + assert_eq!(losers, RACE_WIDTH - 1); +} + +async fn compare_and_swap_race(store: &AzureBlobStore, prefix: &str) { + let key = format!("{prefix}/cas-race"); + let created = store + .create(&key, Bytes::from_static(b"base"), "text/plain") + .await + .expect("create CAS base"); + let ConditionalWrite::Won(base_version) = created else { + panic!("unique CAS base create unexpectedly lost"); + }; + + let barrier = Arc::new(Barrier::new(RACE_WIDTH)); + let mut racers = Vec::with_capacity(RACE_WIDTH); + for index in 0..RACE_WIDTH { + let store = store.clone(); + let key = key.clone(); + let barrier = Arc::clone(&barrier); + let version = base_version.clone(); + racers.push(tokio::spawn(async move { + barrier.wait().await; + store + .update( + &key, + Bytes::from(format!("candidate-{index}")), + "text/plain", + version, + ) + .await + })); + } + + let mut winner_version = None; + let mut losers = 0; + for racer in racers { + match racer + .await + .expect("racer task should join") + .expect("Azure response") + { + ConditionalWrite::Won(version) => { + assert!( + winner_version.replace(version).is_none(), + "multiple CAS winners" + ); + } + ConditionalWrite::LostRace => losers += 1, + } + } + + let winner_version = winner_version.expect("If-Match race must have one winner"); + assert_eq!(losers, RACE_WIDTH - 1); + + let read = store.get(&key).await.expect("read CAS winner"); + assert_eq!(read.version, winner_version); + let next = store + .update( + &key, + Bytes::from_static(b"next"), + "text/plain", + winner_version, + ) + .await + .expect("reuse winning response ETag"); + assert!(matches!(next, ConditionalWrite::Won(_))); +} + +async fn media_primitives(store: &AzureBlobStore, prefix: &str) { + let key = format!("{prefix}/media/0-video.bin"); + let bytes = Bytes::from_static(b"0123456789abcdefghijklmnopqrstuvwxyz"); + store + .put(&key, bytes.clone(), "application/octet-stream") + .await + .expect("put media object"); + + let range = store.get_range(&key, 10..16).await.expect("range read"); + assert_eq!(range, Bytes::from_static(b"abcdef")); + + let streamed = store + .get_stream(&key) + .await + .expect("open media stream") + .try_collect::>() + .await + .expect("stream media chunks") + .concat(); + assert_eq!(streamed, bytes); + + let head = store + .head(&key) + .await + .expect("head media object") + .expect("media object exists"); + assert_eq!(head.size, bytes.len() as u64); + + for suffix in ["1-a.bin", "2-b.bin"] { + store + .put( + &format!("{prefix}/media/{suffix}"), + Bytes::from_static(b"page"), + "application/octet-stream", + ) + .await + .expect("put paged-list object"); + } + + let listed = store + .list_prefix(&format!("{prefix}/media")) + .await + .expect("list media prefix"); + assert_eq!(listed.len(), 3); + assert!(listed.iter().any(|object| object.key == key)); + + let first_page = store + .list_page(Some(&format!("{prefix}/media")), None, 1) + .await + .expect("list bounded media page"); + assert_eq!(first_page.objects.len(), 1); + let continuation = first_page + .continuation_token + .expect("bounded Azure list should return a continuation token"); + let second_page = store + .list_page(Some(&format!("{prefix}/media")), Some(continuation), 2) + .await + .expect("continue bounded media page"); + assert_eq!(second_page.objects.len(), 2); + assert!(second_page.continuation_token.is_none()); + + store.delete(&key).await.expect("delete media object"); + assert!(store + .head(&key) + .await + .expect("head deleted object") + .is_none()); + store + .delete_if_exists(&key) + .await + .expect("idempotent delete of absent object"); +} + +async fn multipart_file_roundtrip(store: &AzureBlobStore, prefix: &str) { + const LARGE_BYTES: usize = 17 * 1024 * 1024 + 37; + + let key = format!("{prefix}/large/multipart.bin"); + let file_path = std::env::temp_dir().join(format!("buzz-{}.bin", Uuid::new_v4())); + let expected = vec![0x5a; LARGE_BYTES]; + tokio::fs::write(&file_path, &expected) + .await + .expect("write bounded multipart fixture"); + + store + .put_file(&key, &file_path, "application/octet-stream") + .await + .expect("multipart upload"); + tokio::fs::remove_file(&file_path) + .await + .expect("remove multipart fixture"); + + let head = store + .head(&key) + .await + .expect("head multipart object") + .expect("multipart object exists"); + assert_eq!(head.size, LARGE_BYTES as u64); + let tail = store + .get_range(&key, (LARGE_BYTES as u64 - 37)..LARGE_BYTES as u64) + .await + .expect("read multipart tail"); + assert_eq!(tail, Bytes::from(vec![0x5a; 37])); +} + +async fn cleanup(store: &AzureBlobStore, prefix: &str) { + let objects = store + .list_prefix(prefix) + .await + .expect("list conformance cleanup prefix"); + for object in objects { + store + .delete_if_exists(&object.key) + .await + .expect("delete conformance object"); + } + assert!(store + .list_prefix(prefix) + .await + .expect("verify conformance cleanup") + .is_empty()); +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8b..40a9ae80b56 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -9,8 +9,7 @@ use crate::validate::{ validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ - extract_at_mentions_with_known, extract_nostr_uris, merge_mentions, strip_code_regions, - MENTION_CAP, + extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, }; /// Extract the thread root event ID from a Nostr tag array. @@ -119,47 +118,82 @@ async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result>, + has_explicit_mentions: bool, +) -> Result, CliError> { + let mut resolved = Vec::new(); + for name in names { + match name_to_pubkeys + .get(name) + .map(Vec::as_slice) + .unwrap_or_default() + { + [pubkey] => resolved.push(pubkey.clone()), + [] if has_explicit_mentions => {} + [] => { + return Err(CliError::Usage(format!( + "mention '@{name}' does not match a current channel member; retry with --mention " + ))) + } + _ if has_explicit_mentions => {} + candidates => { + return Err(CliError::Usage(format!( + "mention '@{name}' is ambiguous; candidates: {}. Retry with --mention ", + candidates.join(", ") + ))) + } + } + } + Ok(resolved) +} + +/// Resolve mention text against the channel membership snapshot. /// -/// Queries kind 39002 (channel members) then kind 0 (profiles), parses -/// display names once, and feeds them to [`extract_at_mentions_with_known`] -/// for multi-word matching. On any I/O or parse failure, returns an empty -/// vec — auto-tagging is best-effort and must never block a send. +/// Returns both the current member set and uniquely name-resolved pubkeys. +/// Lookup failures are fatal when mention processing is requested: publishing +/// visible mention text without its intended `p` tag is worse than not sending. async fn resolve_content_mentions( client: &BuzzClient, channel_id: &str, content: &str, -) -> Vec { - if !content.contains('@') { - return vec![]; + has_explicit_mentions: bool, +) -> Result<(Vec, Vec), CliError> { + let stripped = strip_code_regions(content); + if !stripped.contains('@') && !has_explicit_mentions { + return Ok((vec![], vec![])); } - // 1. Membership list (kind 39002 is parameterized-replaceable, addressed by `d` tag). let members_filter = serde_json::json!({ "kinds": [39002], "#d": [channel_id], "limit": 1, }); - let member_pubkeys = match fetch_member_pubkeys(client, &members_filter).await { - Some(pks) if !pks.is_empty() => pks, - _ => return vec![], - }; + let member_pubkeys = fetch_member_pubkeys(client, &members_filter) + .await + .ok_or_else(|| { + CliError::Other("could not load channel membership for mention preflight".into()) + })?; + + if !stripped.contains('@') { + return Ok((member_pubkeys, vec![])); + } - // 2. Profiles for those members (kind 0). let profiles_filter = serde_json::json!({ "kinds": [0], "authors": member_pubkeys, "limit": member_pubkeys.len(), }); - let profile_events = match fetch_events(client, &profiles_filter).await { - Some(v) => v, - None => return vec![], - }; + let profile_events = fetch_events(client, &profiles_filter) + .await + .ok_or_else(|| { + CliError::Other("could not load member profiles for mention resolution".into()) + })?; - // 3. Single parse: extract (pubkey, display_name) pairs from profile JSON. let mut name_to_pubkeys: std::collections::HashMap> = std::collections::HashMap::new(); - let mut display_names: Vec = Vec::new(); + let mut display_names = Vec::new(); for e in &profile_events { let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else { continue; @@ -178,26 +212,82 @@ async fn resolve_content_mentions( else { continue; }; - let lower = name.to_ascii_lowercase(); name_to_pubkeys - .entry(lower) + .entry(name.to_ascii_lowercase()) .or_default() .push(pubkey.to_string()); display_names.push(name.to_string()); } - // 4. Two-pass extraction: known multi-word names first, single-word fallback. - let known_refs: Vec<&str> = display_names.iter().map(|s| s.as_str()).collect(); - let names = extract_at_mentions_with_known(content, &known_refs); + let known_refs: Vec<&str> = display_names.iter().map(String::as_str).collect(); + let names = extract_at_mentions_with_known(&stripped, &known_refs); + let resolved = resolve_names_to_pubkeys(&names, &name_to_pubkeys, has_explicit_mentions)?; + Ok((member_pubkeys, resolved)) +} + +fn normalize_explicit_mentions(values: &[String]) -> Result, CliError> { + let mut normalized = Vec::new(); + for value in values { + let pubkey = PublicKey::parse(value.trim()) + .map_err(|_| CliError::Usage(format!("invalid --mention pubkey: {value}")))?; + let hex = pubkey.to_hex(); + if !normalized.contains(&hex) { + normalized.push(hex); + } + } + if normalized.len() > MENTION_CAP { + return Err(CliError::Usage(format!( + "too many --mention values (max {MENTION_CAP})" + ))); + } + Ok(normalized) +} + +fn merge_message_mentions( + explicit: &[String], + uri_pubkeys: &[String], + auto_resolved: &[String], +) -> Result, CliError> { + let mut mentions = Vec::new(); + for pubkey in explicit + .iter() + .chain(uri_pubkeys.iter()) + .chain(auto_resolved.iter()) + { + if !mentions.contains(pubkey) { + mentions.push(pubkey.clone()); + } + } + if mentions.len() > MENTION_CAP { + return Err(CliError::Usage(format!( + "too many unique message mentions (max {MENTION_CAP})" + ))); + } + Ok(mentions) +} - // 5. Look up matched names → pubkeys via the map we already built. - names +fn missing_members(mentions: &[String], members: &[String]) -> Vec { + let members: std::collections::HashSet<&str> = members.iter().map(String::as_str).collect(); + mentions .iter() - .flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten()) + .filter(|pk| !members.contains(pk.as_str())) .cloned() .collect() } +fn event_mention_pubkeys(event: &nostr::Event) -> Vec { + event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("p")) + .then(|| parts.get(1).cloned()) + .flatten() + }) + .collect() +} + /// Fetch raw events for `filter` via the relay's `/query` endpoint. /// Returns `None` on any I/O or parse failure. async fn fetch_events( @@ -478,6 +568,7 @@ pub struct SendMessageParams { pub reply_to: Option, pub broadcast: bool, pub files: Vec, + pub mentions: Vec, } pub async fn cmd_send_message( @@ -495,6 +586,30 @@ pub async fn cmd_send_message( } let channel_uuid = parse_uuid(&p.channel_id)?; + let explicit_mentions = normalize_explicit_mentions(&p.mentions)?; + let stripped = strip_code_regions(&p.content); + let uri_pubkeys = extract_nostr_uris(&stripped); + // Supplying any identity explicitly authorizes unresolved or ambiguous @Name text + // as presentation-only, matching Desktop's separate visible-label and p-tag model. + // Uniquely resolvable member names still add their own p-tags; callers must supply + // every intended identity whose visible label cannot be resolved uniquely. + let has_explicit_mentions = !explicit_mentions.is_empty() || !uri_pubkeys.is_empty(); + let (member_pubkeys, auto_resolved) = + resolve_content_mentions(client, &p.channel_id, &p.content, has_explicit_mentions).await?; + let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?; + + let missing = missing_members(&mention_pubkeys, &member_pubkeys); + if !missing.is_empty() { + return Err(CliError::Usage( + serde_json::json!({ + "message": "mentioned pubkeys are not channel members; add them explicitly before retrying", + "missing_member_pubkeys": missing, + "add_member_command": format!("buzz channels add-member --channel {} --pubkey --role ", p.channel_id), + }) + .to_string(), + )); + } + // Upload files and build imeta tags let mut media_tags: Vec> = Vec::new(); let mut media_content = String::new(); @@ -526,16 +641,7 @@ pub async fn cmd_send_message( None }; - // Resolve @name mentions in the author-written body only — not the media markdown we - // append above, which is derived from upload metadata and can't carry `@names`. - let mut auto_resolved = resolve_content_mentions(client, &p.channel_id, &p.content).await; - - // NIP-27: also extract nostr:npub1… inline references (skipping code regions) - let stripped = strip_code_regions(&p.content); - let uri_pubkeys = extract_nostr_uris(&stripped); - merge_mentions(&mut auto_resolved, &uri_pubkeys, MENTION_CAP); - - let mention_refs: Vec<&str> = auto_resolved.iter().map(|s| s.as_str()).collect(); + let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); let builder = match p.kind { Some(45001) => { @@ -572,9 +678,17 @@ pub async fn cmd_send_message( }; let event = client.sign_event(builder)?; - + let emitted_mentions = event_mention_pubkeys(&event); let resp = client.submit_event(event).await?; - println!("{}", normalize_write_response(&resp)); + let mut output: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp)) + .unwrap_or_else(|_| serde_json::json!({ "response": resp })); + if let Some(object) = output.as_object_mut() { + object.insert( + "mention_pubkeys".into(), + serde_json::json!(emitted_mentions), + ); + } + println!("{output}"); Ok(()) } @@ -765,6 +879,7 @@ pub async fn dispatch( reply_to, broadcast, files, + mentions, } => { cmd_send_message( client, @@ -775,6 +890,7 @@ pub async fn dispatch( reply_to, broadcast, files, + mentions, }, ) .await @@ -876,7 +992,11 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys}; + use super::{ + event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, + missing_members, normalize_explicit_mentions, parse_member_pubkeys, + resolve_names_to_pubkeys, + }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, }; @@ -1103,6 +1223,94 @@ mod tests { assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_A]); } + #[test] + fn explicit_mentions_accept_hex_and_npub_and_deduplicate() { + use nostr::ToBech32; + let npub = nostr::PublicKey::from_hex(PK_VALID_A) + .unwrap() + .to_bech32() + .unwrap(); + assert_eq!( + normalize_explicit_mentions(&[PK_VALID_A.into(), npub]).unwrap(), + vec![PK_VALID_A] + ); + assert!(normalize_explicit_mentions(&["not-a-key".into()]).is_err()); + } + + #[test] + fn explicit_mentions_authorize_presentation_text_without_name_resolution() { + let names = vec!["renamed user".into()]; + let profiles = std::collections::HashMap::new(); + assert_eq!( + resolve_names_to_pubkeys(&names, &profiles, true).unwrap(), + Vec::::new() + ); + assert!(resolve_names_to_pubkeys(&names, &profiles, false).is_err()); + } + + #[test] + fn explicit_mentions_authorize_ambiguous_presentation_text() { + let names = vec!["alice".into()]; + let profiles = std::collections::HashMap::from([( + "alice".into(), + vec![PK_VALID_A.into(), PK_VALID_B.into()], + )]); + assert_eq!( + resolve_names_to_pubkeys(&names, &profiles, true).unwrap(), + Vec::::new() + ); + let error = resolve_names_to_pubkeys(&names, &profiles, false).unwrap_err(); + assert!(error.to_string().contains(PK_VALID_A)); + assert!(error.to_string().contains(PK_VALID_B)); + } + + #[test] + fn explicit_mentions_make_all_at_names_presentation_only() { + let names = vec!["alice".into(), "bob".into()]; + let profiles = std::collections::HashMap::from([("alice".into(), vec![PK_VALID_A.into()])]); + assert_eq!( + resolve_names_to_pubkeys(&names, &profiles, true).unwrap(), + vec![PK_VALID_A] + ); + assert!(resolve_names_to_pubkeys(&names, &profiles, false).is_err()); + } + + #[test] + fn combined_mention_union_errors_instead_of_truncating() { + let explicit: Vec = (0..50).map(|i| format!("explicit-{i}")).collect(); + assert!(merge_message_mentions(&explicit, &[], &["resolved-bob".into()]).is_err()); + + let mut with_duplicate = explicit.clone(); + with_duplicate.push(explicit[0].clone()); + assert_eq!( + merge_message_mentions(&with_duplicate, &[explicit[1].clone()], &[]) + .unwrap() + .len(), + 50 + ); + } + + #[test] + fn membership_preflight_lists_only_missing_mentions() { + assert_eq!( + missing_members( + &[PK_VALID_A.into(), PK_VALID_B.into()], + &[PK_VALID_A.into()] + ), + vec![PK_VALID_B] + ); + } + + #[test] + fn mention_evidence_comes_from_signed_event_tags() { + use nostr::{EventBuilder, Keys, Tag}; + let event = EventBuilder::text_note("hello") + .tags(vec![Tag::parse(["p", PK_VALID_A]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert_eq!(event_mention_pubkeys(&event), vec![PK_VALID_A]); + } + // ---- match_profiles_by_name (author resolution for `messages search --author`) ---- fn profile_event( diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 74656258040..df02c65be97 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -369,6 +369,9 @@ pub enum MessagesCmd { /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, + /// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify. + #[arg(long = "mention")] + mentions: Vec, }, /// Send a code diff / patch to a channel SendDiff { diff --git a/crates/buzz-media/Cargo.toml b/crates/buzz-media/Cargo.toml index 530ce69c90a..ba6948390bf 100644 --- a/crates/buzz-media/Cargo.toml +++ b/crates/buzz-media/Cargo.toml @@ -9,6 +9,7 @@ description = "Media storage, validation, and thumbnail generation for Buzz" [dependencies] buzz-core = { workspace = true } +buzz-azure-storage = { workspace = true } nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/buzz-media/src/error.rs b/crates/buzz-media/src/error.rs index c3d180402f1..aa48bd04013 100644 --- a/crates/buzz-media/src/error.rs +++ b/crates/buzz-media/src/error.rs @@ -97,6 +97,16 @@ impl From for MediaError { } } +impl From for MediaError { + fn from(error: buzz_azure_storage::AzureStorageError) -> Self { + if error.is_not_found() { + Self::NotFound + } else { + Self::StorageError(error.to_string()) + } + } +} + impl From for MediaError { fn from(e: serde_json::Error) -> Self { Self::StorageError(e.to_string()) diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index 0e9809af2f9..cc5e9925aa2 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -1,8 +1,10 @@ -//! S3/MinIO storage client. +//! Backend-neutral media object storage for S3/MinIO and Azure Blob. use std::path::Path; use std::pin::Pin; +use std::sync::Arc; +use buzz_azure_storage::AzureBlobStore; use buzz_core::tenant::{CommunityId, TenantContext}; use crate::config::MediaConfig; @@ -12,12 +14,19 @@ use s3::creds::Credentials; use s3::{Bucket, Region}; use serde::{Deserialize, Serialize}; -/// A stream of byte chunks from S3, usable with `axum::body::Body::from_stream()`. +/// A stream of object bytes usable with `axum::body::Body::from_stream()`. pub type ByteStream = Pin> + Send>>; -/// S3-compatible object storage client. +#[derive(Clone)] +enum MediaBackend { + S3(Arc), + Azure(AzureBlobStore), +} + +/// Object storage client selected explicitly from the runtime configuration. +#[derive(Clone)] pub struct MediaStorage { - bucket: Box, + backend: MediaBackend, } impl MediaStorage { @@ -63,7 +72,39 @@ impl MediaStorage { let bucket = Bucket::new(&config.s3_bucket, region, creds) .map_err(|e| MediaError::StorageError(e.to_string()))? .with_path_style(); - Ok(Self { bucket }) + Ok(Self { + backend: MediaBackend::S3(Arc::from(bucket)), + }) + } + + /// Create the configured production backend. + /// + /// `BUZZ_OBJECT_STORAGE_BACKEND` defaults to `s3`. Azure requires + /// `BUZZ_AZURE_STORAGE_ACCOUNT` and `BUZZ_AZURE_MEDIA_CONTAINER`; the + /// Azure SDK then authenticates through workload identity. + pub fn from_runtime_env(config: &MediaConfig) -> Result { + match std::env::var("BUZZ_OBJECT_STORAGE_BACKEND") + .unwrap_or_else(|_| "s3".to_string()) + .to_ascii_lowercase() + .as_str() + { + "s3" => Self::new(config), + "azure" => { + let account = required_env("BUZZ_AZURE_STORAGE_ACCOUNT")?; + let container = required_env("BUZZ_AZURE_MEDIA_CONTAINER")?; + Self::new_azure(&account, &container) + } + backend => Err(MediaError::StorageError(format!( + "unsupported BUZZ_OBJECT_STORAGE_BACKEND '{backend}'; expected s3 or azure" + ))), + } + } + + /// Create an Azure media backend using the Azure credential environment. + pub fn new_azure(account: &str, container: &str) -> Result { + Ok(Self { + backend: MediaBackend::Azure(AzureBlobStore::from_env(account, container)?), + }) } /// Store an object from a byte slice. @@ -71,9 +112,18 @@ impl MediaStorage { /// Used for images, sidecars, and thumbnails. For large video files use /// [`put_file`] to avoid loading the entire blob into RAM. pub async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), MediaError> { - self.bucket - .put_object_with_content_type(key, bytes, content_type) - .await?; + match &self.backend { + MediaBackend::S3(bucket) => { + bucket + .put_object_with_content_type(key, bytes, content_type) + .await?; + } + MediaBackend::Azure(store) => { + store + .put(key, Bytes::copy_from_slice(bytes), content_type) + .await?; + } + } Ok(()) } @@ -90,23 +140,32 @@ impl MediaStorage { ) -> Result<(), MediaError> { const BUF: usize = 8 * 1024 * 1024; // 8 MiB read buffer - let file = tokio::fs::File::open(path) - .await - .map_err(|e| MediaError::Io(e.to_string()))?; - let mut reader = tokio::io::BufReader::with_capacity(BUF, file); - - self.bucket - .put_object_stream_with_content_type(&mut reader, key, content_type) - .await?; + match &self.backend { + MediaBackend::S3(bucket) => { + let file = tokio::fs::File::open(path) + .await + .map_err(|e| MediaError::Io(e.to_string()))?; + let mut reader = tokio::io::BufReader::with_capacity(BUF, file); + bucket + .put_object_stream_with_content_type(&mut reader, key, content_type) + .await?; + } + MediaBackend::Azure(store) => { + store.put_file(key, path, content_type).await?; + } + } Ok(()) } /// Retrieve an object's bytes. pub async fn get(&self, key: &str) -> Result, MediaError> { - match self.bucket.get_object(key).await { - Ok(response) => Ok(response.to_vec()), - Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound), - Err(e) => Err(MediaError::StorageError(e.to_string())), + match &self.backend { + MediaBackend::S3(bucket) => match bucket.get_object(key).await { + Ok(response) => Ok(response.to_vec()), + Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound), + Err(e) => Err(MediaError::StorageError(e.to_string())), + }, + MediaBackend::Azure(store) => Ok(store.get(key).await?.bytes.to_vec()), } } @@ -116,10 +175,20 @@ impl MediaStorage { /// is transferred from S3 — the full object is never loaded into RAM. /// Intended for HTTP 206 range responses on large video blobs. pub async fn get_range(&self, key: &str, start: u64, end: u64) -> Result, MediaError> { - match self.bucket.get_object_range(key, start, Some(end)).await { - Ok(response) => Ok(response.to_vec()), - Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound), - Err(e) => Err(MediaError::StorageError(e.to_string())), + match &self.backend { + MediaBackend::S3(bucket) => { + match bucket.get_object_range(key, start, Some(end)).await { + Ok(response) => Ok(response.to_vec()), + Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound), + Err(e) => Err(MediaError::StorageError(e.to_string())), + } + } + MediaBackend::Azure(store) => { + let end_exclusive = end.checked_add(1).ok_or_else(|| { + MediaError::StorageError("invalid inclusive range end".to_string()) + })?; + Ok(store.get_range(key, start..end_exclusive).await?.to_vec()) + } } } @@ -129,48 +198,68 @@ impl MediaStorage { /// The full object is never buffered — intended for streaming large /// blobs (video) directly into HTTP responses via `Body::from_stream()`. pub async fn get_stream(&self, key: &str) -> Result { - let response = self - .bucket - .get_object_stream(key) - .await - .map_err(|e| MediaError::StorageError(e.to_string()))?; - - if response.status_code == 404 { - return Err(MediaError::NotFound); + match &self.backend { + MediaBackend::S3(bucket) => { + let response = bucket + .get_object_stream(key) + .await + .map_err(|e| MediaError::StorageError(e.to_string()))?; + if response.status_code == 404 { + return Err(MediaError::NotFound); + } + let stream = futures_util::StreamExt::map(response.bytes, |chunk| { + chunk.map_err(|e| MediaError::StorageError(e.to_string())) + }); + Ok(Box::pin(stream)) + } + MediaBackend::Azure(store) => { + let stream = store.get_stream(key).await?; + Ok(Box::pin(futures_util::StreamExt::map(stream, |chunk| { + chunk.map_err(MediaError::from) + }))) + } } - - let stream = futures_util::StreamExt::map(response.bytes, |chunk| { - chunk.map_err(|e| MediaError::StorageError(e.to_string())) - }); - Ok(Box::pin(stream)) } /// Check if an object exists. Returns false on 404. pub async fn head(&self, key: &str) -> Result { - match self.bucket.head_object(key).await { - Ok(_) => Ok(true), - Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(false), - Err(e) => Err(MediaError::StorageError(e.to_string())), + match &self.backend { + MediaBackend::S3(bucket) => match bucket.head_object(key).await { + Ok(_) => Ok(true), + Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(false), + Err(e) => Err(MediaError::StorageError(e.to_string())), + }, + MediaBackend::Azure(store) => Ok(store.head(key).await?.is_some()), } } /// Delete an object. Returns an error on failure — callers decide whether to propagate. pub async fn delete(&self, key: &str) -> Result<(), MediaError> { - self.bucket - .delete_object(key) - .await - .map_err(|e| MediaError::StorageError(e.to_string()))?; + match &self.backend { + MediaBackend::S3(bucket) => { + bucket + .delete_object(key) + .await + .map_err(|e| MediaError::StorageError(e.to_string()))?; + } + MediaBackend::Azure(store) => store.delete_if_exists(key).await?, + } Ok(()) } /// HEAD with metadata — returns Content-Length (size). pub async fn head_with_metadata(&self, key: &str) -> Result, MediaError> { - match self.bucket.head_object(key).await { - Ok((result, _)) => Ok(Some(BlobHeadMeta { - size: result.content_length.unwrap_or(0) as u64, + match &self.backend { + MediaBackend::S3(bucket) => match bucket.head_object(key).await { + Ok((result, _)) => Ok(Some(BlobHeadMeta { + size: result.content_length.unwrap_or(0) as u64, + })), + Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(None), + Err(e) => Err(MediaError::StorageError(e.to_string())), + }, + MediaBackend::Azure(store) => Ok(store.head(key).await?.map(|metadata| BlobHeadMeta { + size: metadata.size, })), - Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(None), - Err(e) => Err(MediaError::StorageError(e.to_string())), } } @@ -196,8 +285,8 @@ impl MediaStorage { sha256: &str, ) -> Result { let key = Self::ctx_sidecar_key(ctx, sha256); - let resp = self.bucket.get_object(&key).await?; - let meta: BlobMeta = serde_json::from_slice(&resp.to_vec())?; + let bytes = self.get(&key).await?; + let meta: BlobMeta = serde_json::from_slice(&bytes)?; Ok(meta) } @@ -244,28 +333,55 @@ impl MediaStorage { continuation_token: Option, max_keys: usize, ) -> Result { - let (result, _status) = self - .bucket - .list_page( - String::new(), - None, - continuation_token, - None, - Some(max_keys), - ) - .await?; - Ok(crate::bucket_index::Page { - objects: result - .contents - .into_iter() - .map(|obj| (obj.key, obj.size)) - .collect(), - next_continuation_token: result.next_continuation_token, - is_truncated: result.is_truncated, - }) + match &self.backend { + MediaBackend::S3(bucket) => { + let (result, _status) = bucket + .list_page( + String::new(), + None, + continuation_token, + None, + Some(max_keys), + ) + .await?; + Ok(crate::bucket_index::Page { + objects: result + .contents + .into_iter() + .map(|obj| (obj.key, obj.size)) + .collect(), + next_continuation_token: result.next_continuation_token, + is_truncated: result.is_truncated, + }) + } + MediaBackend::Azure(store) => { + let page = store.list_page(None, continuation_token, max_keys).await?; + let is_truncated = page.continuation_token.is_some(); + Ok(crate::bucket_index::Page { + objects: page + .objects + .into_iter() + .map(|object| (object.key, object.size)) + .collect(), + next_continuation_token: page.continuation_token, + is_truncated, + }) + } + } } } +fn required_env(name: &str) -> Result { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + MediaError::StorageError(format!( + "{name} is required when BUZZ_OBJECT_STORAGE_BACKEND=azure" + )) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -303,9 +419,12 @@ mod tests { fn static_keys_build_client_with_configured_region() { let storage = MediaStorage::new(&storage_config("buzz_dev", "buzz_dev_secret")) .expect("static creds should build a client"); - match storage.bucket.region { - Region::Custom { ref region, .. } => assert_eq!(region, "us-west-2"), - other => panic!("expected Custom region, got {other:?}"), + match &storage.backend { + MediaBackend::S3(bucket) => match &bucket.region { + Region::Custom { region, .. } => assert_eq!(region, "us-west-2"), + other => panic!("expected Custom region, got {other:?}"), + }, + MediaBackend::Azure(_) => panic!("expected S3 backend"), } } diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 01f78a2d496..c39ef0079cd 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -62,6 +62,7 @@ base64 = "0.22" buzz-sdk = { workspace = true } buzz-workflow = { workspace = true, features = ["reqwest"] } buzz-media = { workspace = true } +buzz-azure-storage = { workspace = true } s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] } tempfile = "3" bytes = "1" diff --git a/crates/buzz-relay/src/api/git/store.rs b/crates/buzz-relay/src/api/git/store.rs index 43d210e6481..14aaa016515 100644 --- a/crates/buzz-relay/src/api/git/store.rs +++ b/crates/buzz-relay/src/api/git/store.rs @@ -26,6 +26,7 @@ use std::sync::Arc; +use buzz_azure_storage::{AzureBlobStore, BlobVersion, ConditionalWrite}; use bytes::Bytes; use s3::creds::Credentials; use s3::error::S3Error; @@ -91,6 +92,9 @@ pub enum StoreError { /// Any other backend / transport error. #[error("s3 backend error: {0}")] Backend(#[from] S3Error), + /// Azure Blob backend or transport failure. + #[error("azure blob backend error: {0}")] + AzureBackend(#[from] buzz_azure_storage::AzureStorageError), /// Invalid storage configuration detected at client construction. #[error("git store config error: {0}")] Config(String), @@ -167,8 +171,15 @@ impl From for StoreError { /// Object-store client for git refs. #[derive(Clone)] +enum GitBackend { + S3(Arc), + Azure(AzureBlobStore), +} + +#[derive(Clone)] +/// Backend-neutral object-store client for Buzz Git refs and immutable objects. pub struct GitStore { - bucket: Arc, + backend: GitBackend, } impl GitStore { @@ -213,7 +224,49 @@ impl GitStore { .map_err(StoreError::Backend)? .with_path_style(); Ok(Self { - bucket: Arc::from(bucket), + backend: GitBackend::S3(Arc::from(bucket)), + }) + } + + /// Build the configured production Git backend. + /// + /// Azure uses `BUZZ_AZURE_STORAGE_ACCOUNT` and + /// `BUZZ_AZURE_GIT_CONTAINER`, authenticated through workload identity. + pub fn from_runtime_env( + endpoint: &str, + access_key: &str, + secret_key: &str, + bucket_name: &str, + region: &str, + ) -> Result { + match std::env::var("BUZZ_OBJECT_STORAGE_BACKEND") + .unwrap_or_else(|_| "s3".to_string()) + .to_ascii_lowercase() + .as_str() + { + "s3" => Self::new(endpoint, access_key, secret_key, bucket_name, region), + "azure" => { + let account = required_env("BUZZ_AZURE_STORAGE_ACCOUNT")?; + let container = required_env("BUZZ_AZURE_GIT_CONTAINER")?; + Self::new_azure(&account, &container) + } + backend => Err(StoreError::Config(format!( + "unsupported BUZZ_OBJECT_STORAGE_BACKEND '{backend}'; expected s3 or azure" + ))), + } + } + + /// Build an Azure Git backend from the Azure credential environment. + pub fn new_azure(account: &str, container: &str) -> Result { + Ok(Self { + backend: GitBackend::Azure(AzureBlobStore::from_env(account, container)?), + }) + } + + #[cfg(test)] + fn new_azurite(container: &str) -> Result { + Ok(Self { + backend: GitBackend::Azure(AzureBlobStore::for_azurite(container)?), }) } @@ -258,23 +311,37 @@ impl GitStore { content_type: &str, ) -> Result { let key = Self::content_key(prefix, bytes); - let mut headers = axum::http::HeaderMap::new(); - headers.insert(axum::http::header::IF_NONE_MATCH, "*".parse().unwrap()); - match self - .bucket - .put_object_with_content_type_and_headers(&key, bytes, content_type, Some(headers)) - .await - { - Ok(resp) if (200..300).contains(&resp.status_code()) => Ok(key), - // 412 on a content-addressed key means the key already holds the - // same bytes (by construction — the key is the digest). A1 is - // preserved without a defensive GET. - Err(S3Error::HttpFailWithBody(412, _)) => Ok(key), - Ok(resp) => Err(StoreError::Backend(S3Error::HttpFailWithBody( - resp.status_code(), - "unexpected status".into(), - ))), - Err(e) => Err(StoreError::Backend(e)), + match &self.backend { + GitBackend::S3(bucket) => { + let mut headers = axum::http::HeaderMap::new(); + headers.insert(axum::http::header::IF_NONE_MATCH, "*".parse().unwrap()); + match bucket + .put_object_with_content_type_and_headers( + &key, + bytes, + content_type, + Some(headers), + ) + .await + { + Ok(resp) if (200..300).contains(&resp.status_code()) => Ok(key), + // 412 on a content-addressed key means the key already holds the + // same bytes (by construction — the key is the digest). A1 is + // preserved without a defensive GET. + Err(S3Error::HttpFailWithBody(412, _)) => Ok(key), + Ok(resp) => Err(StoreError::Backend(S3Error::HttpFailWithBody( + resp.status_code(), + "unexpected status".into(), + ))), + Err(e) => Err(StoreError::Backend(e)), + } + } + GitBackend::Azure(store) => { + store + .create(&key, Bytes::copy_from_slice(bytes), content_type) + .await?; + Ok(key) + } } } @@ -293,25 +360,38 @@ impl GitStore { /// trusting it. pub async fn put_idx(&self, pack_digest: &str, idx_bytes: &[u8]) -> Result { let key = Self::idx_key_for_pack_digest(pack_digest)?; - let mut headers = axum::http::HeaderMap::new(); - headers.insert(axum::http::header::IF_NONE_MATCH, "*".parse().unwrap()); - match self - .bucket - .put_object_with_content_type_and_headers( - &key, - idx_bytes, - "application/x-git-index", - Some(headers), - ) - .await - { - Ok(resp) if (200..300).contains(&resp.status_code()) => Ok(key), - Err(S3Error::HttpFailWithBody(412, _)) => Ok(key), - Ok(resp) => Err(StoreError::Backend(S3Error::HttpFailWithBody( - resp.status_code(), - "unexpected status".into(), - ))), - Err(e) => Err(StoreError::Backend(e)), + match &self.backend { + GitBackend::S3(bucket) => { + let mut headers = axum::http::HeaderMap::new(); + headers.insert(axum::http::header::IF_NONE_MATCH, "*".parse().unwrap()); + match bucket + .put_object_with_content_type_and_headers( + &key, + idx_bytes, + "application/x-git-index", + Some(headers), + ) + .await + { + Ok(resp) if (200..300).contains(&resp.status_code()) => Ok(key), + Err(S3Error::HttpFailWithBody(412, _)) => Ok(key), + Ok(resp) => Err(StoreError::Backend(S3Error::HttpFailWithBody( + resp.status_code(), + "unexpected status".into(), + ))), + Err(e) => Err(StoreError::Backend(e)), + } + } + GitBackend::Azure(store) => { + store + .create( + &key, + Bytes::copy_from_slice(idx_bytes), + "application/x-git-index", + ) + .await?; + Ok(key) + } } } @@ -345,10 +425,17 @@ impl GitStore { /// detectability. This raw `get` exists for the pointer (whose key is not a /// digest). pub async fn get(&self, key: &str) -> Result { - match self.bucket.get_object(key).await { - Ok(resp) => Ok(Bytes::from(resp.to_vec())), - Err(S3Error::HttpFailWithBody(404, _)) => Err(StoreError::NotFound(key.into())), - Err(e) => Err(StoreError::Backend(e)), + match &self.backend { + GitBackend::S3(bucket) => match bucket.get_object(key).await { + Ok(resp) => Ok(Bytes::from(resp.to_vec())), + Err(S3Error::HttpFailWithBody(404, _)) => Err(StoreError::NotFound(key.into())), + Err(e) => Err(StoreError::Backend(e)), + }, + GitBackend::Azure(store) => match store.get(key).await { + Ok(object) => Ok(object.bytes), + Err(error) if error.is_not_found() => Err(StoreError::NotFound(key.into())), + Err(error) => Err(StoreError::AzureBackend(error)), + }, } } @@ -404,30 +491,44 @@ impl GitStore { /// GET an object after rejecting bodies larger than `max_bytes`. pub async fn get_limited(&self, key: &str, max_bytes: u64) -> Result { - let (head, status) = self.bucket.head_object(key).await.map_err(|e| match e { - S3Error::HttpFailWithBody(404, _) => StoreError::NotFound(key.into()), - other => StoreError::Backend(other), - })?; - if status == 404 { - return Err(StoreError::NotFound(key.into())); - } - if !(200..300).contains(&status) { - return Err(StoreError::Backend(S3Error::HttpFailWithBody( - status, - "unexpected status".into(), - ))); - } - if let Some(content_length) = head.content_length { - let size = u64::try_from(content_length).unwrap_or(u64::MAX); - if size > max_bytes { - return Err(StoreError::ObjectTooLarge { - key: key.into(), - size, - max: max_bytes, - }); + let object_size = match &self.backend { + GitBackend::S3(bucket) => { + let (head, status) = bucket.head_object(key).await.map_err(|e| match e { + S3Error::HttpFailWithBody(404, _) => StoreError::NotFound(key.into()), + other => StoreError::Backend(other), + })?; + if status == 404 { + return Err(StoreError::NotFound(key.into())); + } + if !(200..300).contains(&status) { + return Err(StoreError::Backend(S3Error::HttpFailWithBody( + status, + "unexpected status".into(), + ))); + } + head.content_length + .map(|size| u64::try_from(size).unwrap_or(u64::MAX)) + } + GitBackend::Azure(store) => store.head(key).await?.map(|meta| meta.size), + }; + let Some(size) = object_size else { + if matches!(&self.backend, GitBackend::Azure(_)) { + return Err(StoreError::NotFound(key.into())); } + return self.finish_limited_get(key, max_bytes).await; + }; + if size > max_bytes { + return Err(StoreError::ObjectTooLarge { + key: key.into(), + size, + max: max_bytes, + }); } + self.finish_limited_get(key, max_bytes).await + } + + async fn finish_limited_get(&self, key: &str, max_bytes: u64) -> Result { let bytes = self.get(key).await?; let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX); if size > max_bytes { @@ -453,23 +554,30 @@ impl GitStore { /// the snapshot consistent (A2: a single GET observes a single committed /// object). Verified empirically in `probe::probe_get_exposes_etag`. pub async fn get_pointer(&self, key: &str) -> Result, StoreError> { - match self.bucket.get_object(key).await { - Ok(resp) => { - let headers = resp.headers(); - let etag = headers - .get("etag") - .or_else(|| headers.get("ETag")) - .cloned() - .ok_or_else(|| { - StoreError::Backend(S3Error::HttpFailWithBody( - 500, - "GET pointer: response missing ETag".into(), - )) - })?; - Ok(Some((ETag(etag), Bytes::from(resp.to_vec())))) - } - Err(S3Error::HttpFailWithBody(404, _)) => Ok(None), - Err(e) => Err(StoreError::Backend(e)), + match &self.backend { + GitBackend::S3(bucket) => match bucket.get_object(key).await { + Ok(resp) => { + let headers = resp.headers(); + let etag = headers + .get("etag") + .or_else(|| headers.get("ETag")) + .cloned() + .ok_or_else(|| { + StoreError::Backend(S3Error::HttpFailWithBody( + 500, + "GET pointer: response missing ETag".into(), + )) + })?; + Ok(Some((ETag(etag), Bytes::from(resp.to_vec())))) + } + Err(S3Error::HttpFailWithBody(404, _)) => Ok(None), + Err(e) => Err(StoreError::Backend(e)), + }, + GitBackend::Azure(store) => match store.get(key).await { + Ok(object) => Ok(Some((ETag(object.version.etag), object.bytes))), + Err(error) if error.is_not_found() => Ok(None), + Err(error) => Err(StoreError::AzureBackend(error)), + }, } } @@ -484,28 +592,62 @@ impl GitStore { body: &[u8], precond: Precond, ) -> Result { - let mut headers = axum::http::HeaderMap::new(); - match &precond { - Precond::IfNoneMatchStar => { - headers.insert(axum::http::header::IF_NONE_MATCH, "*".parse().unwrap()); + match &self.backend { + GitBackend::S3(bucket) => { + let mut headers = axum::http::HeaderMap::new(); + match &precond { + Precond::IfNoneMatchStar => { + headers.insert(axum::http::header::IF_NONE_MATCH, "*".parse().unwrap()); + } + Precond::IfMatch(ETag(tag)) => { + headers.insert( + axum::http::header::IF_MATCH, + tag.parse().map_err(|_| { + StoreError::Backend(S3Error::HttpFailWithBody( + 400, + format!("invalid etag {tag}"), + )) + })?, + ); + } + } + let result = bucket + .put_object_with_content_type_and_headers( + key, + body, + "application/json", + Some(headers), + ) + .await; + Self::classify_cas(result) } - Precond::IfMatch(ETag(tag)) => { - headers.insert( - axum::http::header::IF_MATCH, - tag.parse().map_err(|_| { - StoreError::Backend(S3Error::HttpFailWithBody( - 400, - format!("invalid etag {tag}"), - )) - })?, - ); + GitBackend::Azure(store) => { + let result = match precond { + Precond::IfNoneMatchStar => { + store + .create(key, Bytes::copy_from_slice(body), "application/json") + .await? + } + Precond::IfMatch(ETag(etag)) => { + store + .update( + key, + Bytes::copy_from_slice(body), + "application/json", + BlobVersion { + etag, + version: None, + }, + ) + .await? + } + }; + Ok(match result { + ConditionalWrite::Won(version) => CasOutcome::Won(ETag(version.etag)), + ConditionalWrite::LostRace => CasOutcome::LostRace, + }) } } - let result = self - .bucket - .put_object_with_content_type_and_headers(key, body, "application/json", Some(headers)) - .await; - Self::classify_cas(result) } /// Map a rust-s3 PUT outcome to a `CasOutcome`. @@ -615,7 +757,7 @@ impl GitStore { // -- Phase 2: if_match_race ----------------------------------------------- // Seed the pointer with a known value, then race N IfMatch updates. let seed = b"probe-pointer-seed".to_vec(); - let _ = self.bucket.delete_object(&pointer_key).await; // ignore 404 + let _ = self.delete_if_exists(&pointer_key).await; let seed_outcome = self .put_pointer(&pointer_key, &seed, Precond::IfNoneMatchStar) .await?; @@ -725,7 +867,7 @@ impl GitStore { let body = format!("probe-inm-race-{nonce}-{round}").into_bytes(); let key = Self::content_key("probe/inm-race", &body); // Clean slate. - let _ = self.bucket.delete_object(&key).await; + let _ = self.delete_if_exists(&key).await; let arc_self: Arc<&Self> = Arc::new(self); let mut tasks = Vec::with_capacity(cfg.race_width); for _ in 0..cfg.race_width { @@ -868,7 +1010,7 @@ impl GitStore { // Cleanup pointer (immutable probe writes accumulate by design; the // bucket's retention policy handles them, not the probe). - let _ = self.bucket.delete_object(&pointer_key).await; + let _ = self.delete_if_exists(&pointer_key).await; Ok(ProbeReport { race_width: cfg.race_width, @@ -888,25 +1030,64 @@ impl GitStore { /// we need to *see* 412 outcomes rather than swallow them as idempotent. /// Returns the HTTP status code on success-or-412; bubbles other errors. async fn put_immutable_raw(&self, key: &str, bytes: &[u8]) -> Result { - let mut headers = axum::http::HeaderMap::new(); - headers.insert(axum::http::header::IF_NONE_MATCH, "*".parse().unwrap()); - match self - .bucket - .put_object_with_content_type_and_headers( - key, - bytes, - "application/octet-stream", - Some(headers), - ) - .await - { - Ok(resp) => Ok(resp.status_code()), - Err(S3Error::HttpFailWithBody(412, _)) => Ok(412), - Err(e) => Err(StoreError::Backend(e)), + match &self.backend { + GitBackend::S3(bucket) => { + let mut headers = axum::http::HeaderMap::new(); + headers.insert(axum::http::header::IF_NONE_MATCH, "*".parse().unwrap()); + match bucket + .put_object_with_content_type_and_headers( + key, + bytes, + "application/octet-stream", + Some(headers), + ) + .await + { + Ok(resp) => Ok(resp.status_code()), + Err(S3Error::HttpFailWithBody(412, _)) => Ok(412), + Err(e) => Err(StoreError::Backend(e)), + } + } + GitBackend::Azure(store) => Ok( + match store + .create( + key, + Bytes::copy_from_slice(bytes), + "application/octet-stream", + ) + .await? + { + ConditionalWrite::Won(_) => 201, + ConditionalWrite::LostRace => 412, + }, + ), + } + } + + async fn delete_if_exists(&self, key: &str) -> Result<(), StoreError> { + match &self.backend { + GitBackend::S3(bucket) => match bucket.delete_object(key).await { + Ok(_) | Err(S3Error::HttpFailWithBody(404, _)) => Ok(()), + Err(error) => Err(StoreError::Backend(error)), + }, + GitBackend::Azure(store) => Ok(store.delete_if_exists(key).await?), + } + } + + #[cfg(test)] + fn s3_bucket(&self) -> Option<&Bucket> { + match &self.backend { + GitBackend::S3(bucket) => Some(bucket.as_ref()), + GitBackend::Azure(_) => None, } } } +fn required_env(name: &str) -> Result { + std::env::var(name) + .map_err(|_| StoreError::Config(format!("required environment variable {name} is not set"))) +} + #[cfg(test)] mod tests { use super::*; @@ -952,7 +1133,7 @@ mod tests { "us-west-2", ) .expect("static creds should build a git store"); - match store.bucket.region { + match store.s3_bucket().expect("S3 constructor").region { Region::Custom { ref region, .. } => assert_eq!(region, "us-west-2"), ref other => panic!("expected Custom region, got {other:?}"), } @@ -979,6 +1160,25 @@ mod tests { ); } } + + #[tokio::test] + async fn azure_git_backend_passes_the_production_conformance_gate() { + if std::env::var("BUZZ_GIT_AZURITE_TEST").as_deref() != Ok("1") { + eprintln!("skipping: set BUZZ_GIT_AZURITE_TEST=1 and start Azurite"); + return; + } + let store = GitStore::new_azurite("buzz-conformance").expect("connect to Azurite"); + let report = store + .run_conformance_probe(ProbeConfig { + race_width: 8, + race_rounds: 2, + }) + .await + .expect("Azure Git/CAS conformance gate"); + assert_eq!(report.race_width, 8); + assert_eq!(report.race_rounds, 2); + assert_eq!(report.transport_drops, 0); + } } #[cfg(test)] @@ -1024,8 +1224,8 @@ mod probe { let key = format!("probe/cas-{}.txt", uuid::Uuid::new_v4()); let mut hdrs = axum::http::HeaderMap::new(); hdrs.insert(axum::http::header::IF_NONE_MATCH, "*".parse().unwrap()); - let r1 = st - .bucket + let bucket = st.s3_bucket().expect("S3 probe store"); + let r1 = bucket .put_object_with_content_type_and_headers( &key, b"first", @@ -1034,12 +1234,11 @@ mod probe { ) .await; assert!((200..300).contains(&r1.expect("first ok").status_code())); - let r2 = st - .bucket + let r2 = bucket .put_object_with_content_type_and_headers(&key, b"second", "text/plain", Some(hdrs)) .await; assert!(matches!(r2, Err(S3Error::HttpFailWithBody(412, _)))); - let _ = st.bucket.delete_object(&key).await; + let _ = bucket.delete_object(&key).await; } #[tokio::test] @@ -1117,8 +1316,9 @@ mod probe { assert_eq!(etag_now, e2, "get_pointer etag matches PUT-response etag"); // Cleanup. - let _ = st.bucket.delete_object(&pkey).await; - let _ = st.bucket.delete_object(&key).await; + let bucket = st.s3_bucket().expect("S3 probe store"); + let _ = bucket.delete_object(&pkey).await; + let _ = bucket.delete_object(&key).await; } /// End-to-end conformance probe against MinIO. This is the same code path @@ -1149,16 +1349,17 @@ mod probe { } let st = store(); let key = format!("probe/etag-{}.txt", uuid::Uuid::new_v4()); - st.bucket + let bucket = st.s3_bucket().expect("S3 probe store"); + bucket .put_object_with_content_type(&key, b"hi", "text/plain") .await .expect("put"); - let resp = st.bucket.get_object(&key).await.expect("get"); + let resp = bucket.get_object(&key).await.expect("get"); let headers = resp.headers(); eprintln!("GET headers: {headers:?}"); let etag = headers.get("etag").or_else(|| headers.get("ETag")).cloned(); assert!(etag.is_some(), "GET response must carry ETag header"); eprintln!("ETag from GET: {etag:?}"); - let _ = st.bucket.delete_object(&key).await; + let _ = bucket.delete_object(&key).await; } } diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 3ed820d3c50..2c9ee146e34 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -102,6 +102,7 @@ async fn main() -> anyhow::Result<()> { // spans under the correct service identity. let resource = telemetry::service_resource(); let tracer_init = telemetry::try_init_tracer(resource.clone()); + let otel_enabled = matches!(&tracer_init, telemetry::TracerInit::Enabled(_)); let otel_layer = match &tracer_init { telemetry::TracerInit::Enabled(p) => { use opentelemetry::trace::TracerProvider as _; @@ -109,12 +110,18 @@ async fn main() -> anyhow::Result<()> { } _ => None, }; + let trace_context_lookup = telemetry::TraceContextLookup::default(); + let trace_context_lookup_layer = otel_enabled.then(|| { + trace_context_lookup + .clone() + .with_filter(tracing_subscriber::filter::LevelFilter::OFF) + }); tracing_subscriber::registry() .with( fmt::layer() .json() - .flatten_event(true) + .event_format(trace_context_lookup.json_formatter(otel_enabled)) .with_filter(log_env_filter(std::env::var("RUST_LOG").ok().as_deref())), ) .with(otel_layer.map(|layer| { @@ -122,6 +129,7 @@ async fn main() -> anyhow::Result<()> { std::env::var("BUZZ_OTEL_FILTER").ok().as_deref(), )) })) + .with(trace_context_lookup_layer) .init(); // Log any exporter-build failure now that the subscriber is installed. @@ -429,11 +437,20 @@ async fn main() -> anyhow::Result<()> { .media .validate() .map_err(|e| anyhow::anyhow!("invalid media config: {e}"))?; - let media_storage = buzz_media::MediaStorage::new(&config.media) + let media_storage = buzz_media::MediaStorage::from_runtime_env(&config.media) .map_err(|e| anyhow::anyhow!("failed to initialize media storage: {e}"))?; info!("Media storage connected"); + let git_store = buzz_relay::api::git::store::GitStore::from_runtime_env( + &config.media.s3_endpoint, + &config.media.s3_access_key, + &config.media.s3_secret_key, + &config.media.s3_bucket, + &config.media.s3_region, + ) + .map_err(|e| anyhow::anyhow!("failed to initialize git object storage: {e}"))?; + info!("Git object storage connected"); - let (app_state, audit_shutdown) = AppState::new( + let (app_state, audit_shutdown) = AppState::new_with_git_store( config.clone(), db, redis_health_pool, @@ -444,6 +461,7 @@ async fn main() -> anyhow::Result<()> { Arc::clone(&workflow_engine), relay_keypair, media_storage, + git_store, ); let state = Arc::new(app_state); @@ -475,7 +493,7 @@ async fn main() -> anyhow::Result<()> { info!(runtime_id = %runtime_id, "Inter-relay mesh started"); } - // Git-on-object-storage: admit the configured S3/MinIO backend against the + // Git-on-object-storage: admit the configured backend against the // linearizable conditional-write axiom (A3) before serving git traffic. // Failure is fatal: a backend that cannot satisfy pointer CAS invalidates // the manifest-pointer protocol. This is a deployment gate, not a proof. diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 758c001b966..468d7485393 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -645,6 +645,47 @@ impl AppState { workflow_engine: Arc, relay_keypair: nostr::Keys, media_storage: MediaStorage, + ) -> (Self, AuditShutdownHandle) { + let git_store = crate::api::git::store::GitStore::new( + &config.media.s3_endpoint, + &config.media.s3_access_key, + &config.media.s3_secret_key, + &config.media.s3_bucket, + &config.media.s3_region, + ) + .expect("media storage was already constructed with this S3 config"); + Self::new_with_git_store( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + relay_keypair, + media_storage, + git_store, + ) + } + + /// Constructs `AppState` with an explicitly selected Git object backend. + /// + /// Production uses this constructor so storage configuration errors can + /// fail startup cleanly instead of panicking inside state assembly. + #[allow(clippy::too_many_arguments)] + pub fn new_with_git_store( + config: Config, + db: Db, + redis_pool: deadpool_redis::Pool, + audit: impl Into>, + pubsub: Arc, + auth: AuthService, + search: SearchService, + workflow_engine: Arc, + relay_keypair: nostr::Keys, + media_storage: MediaStorage, + git_store: crate::api::git::store::GitStore, ) -> (Self, AuditShutdownHandle) { let max_connections = config.max_connections; let max_concurrent_handlers = config.max_concurrent_handlers; @@ -691,14 +732,6 @@ impl AppState { let git_max_concurrent_ops = config.git_max_concurrent_ops; let media_max_concurrent_uploads = config.media_max_concurrent_uploads; - let git_store = crate::api::git::store::GitStore::new( - &config.media.s3_endpoint, - &config.media.s3_access_key, - &config.media.s3_secret_key, - &config.media.s3_bucket, - &config.media.s3_region, - ) - .expect("media storage was already constructed with this S3 config"); let git_pack_cache = Arc::new( crate::api::git::pack_cache::GitPackCache::new( &config.git_pack_cache_path, diff --git a/crates/buzz-relay/src/telemetry.rs b/crates/buzz-relay/src/telemetry.rs index 11c6d035128..91bd92f0f3e 100644 --- a/crates/buzz-relay/src/telemetry.rs +++ b/crates/buzz-relay/src/telemetry.rs @@ -23,9 +23,157 @@ //! - `OTEL_TRACES_SAMPLER` (default: `parentbased_always_on`) //! - `OTEL_TRACES_SAMPLER_ARG` +use std::{ + fmt, + sync::{Arc, OnceLock}, +}; + +use opentelemetry::trace::{SpanId, TraceContextExt as _, TraceId}; use opentelemetry_otlp::ExporterBuildError; use opentelemetry_sdk::{resource::EnvResourceDetector, trace::SdkTracerProvider, Resource}; -use tracing_subscriber::EnvFilter; +use tracing::{Event, Subscriber}; +use tracing_subscriber::{ + fmt::{ + format::{Format, FormatEvent, FormatFields, Json, Writer}, + FmtContext, + }, + registry::LookupSpan, + EnvFilter, Layer, +}; + +/// Captures the subscriber dispatch used to resolve tracing span IDs to their +/// OpenTelemetry contexts. +#[derive(Clone, Default)] +pub struct TraceContextLookup { + dispatch: Arc>, +} + +impl TraceContextLookup { + /// Build a JSON formatter backed by this subscriber dispatch lookup. + pub fn json_formatter(&self, enabled: bool) -> TraceContextJson { + TraceContextJson { + inner: tracing_subscriber::fmt::format().json().flatten_event(true), + enabled, + context_lookup: self.clone(), + } + } + + fn nearest_otel_context(&self, span_id: &tracing::span::Id) -> Option { + let dispatch = self.dispatch.get()?.upgrade()?; + let registry = dispatch.downcast_ref::()?; + + let context = registry.span(span_id)?.scope().find_map(|span| { + let context = tracing_opentelemetry::get_otel_context(&span.id(), &dispatch)?; + context.span().span_context().is_valid().then_some(context) + }); + context + } +} + +impl Layer for TraceContextLookup { + fn on_register_dispatch(&self, subscriber: &tracing::Dispatch) { + let _ = self.dispatch.set(subscriber.downgrade()); + } +} + +/// JSON event formatter that adds the active OpenTelemetry trace context. +/// +/// Datadog recognizes the OpenTelemetry-standard `trace_id` and `span_id` +/// fields when they are lowercase hexadecimal strings. Events outside a valid +/// OpenTelemetry span retain the standard `tracing-subscriber` JSON format. +pub struct TraceContextJson { + inner: Format, + enabled: bool, + context_lookup: TraceContextLookup, +} + +struct CorrelationWriter<'writer> { + inner: Writer<'writer>, + trace_id: TraceId, + span_id: SpanId, + injected: bool, +} + +impl fmt::Write for CorrelationWriter<'_> { + fn write_str(&mut self, value: &str) -> fmt::Result { + if self.injected { + return self.inner.write_str(value); + } + + let Some(object_start) = value.find('{') else { + return self.inner.write_str(value); + }; + self.inner.write_str(&value[..=object_start])?; + write!( + self.inner, + "\"trace_id\":\"{}\",\"span_id\":\"{}\",", + self.trace_id, self.span_id + )?; + self.injected = true; + self.inner.write_str(&value[object_start + 1..]) + } +} + +impl FormatEvent for TraceContextJson +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, + N: for<'writer> FormatFields<'writer> + 'static, +{ + fn format_event( + &self, + ctx: &FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &Event<'_>, + ) -> fmt::Result { + if !self.enabled { + return self.inner.format_event(ctx, writer, event); + } + + let otel_context = match event.parent() { + Some(span_id) => self.context_lookup.nearest_otel_context(span_id), + None if event.is_contextual() => Some(opentelemetry::Context::current()), + None => None, + }; + let Some(otel_context) = otel_context else { + return self.inner.format_event(ctx, writer, event); + }; + let otel_span = otel_context.span(); + let span_context = otel_span.span_context(); + + if !span_context.is_valid() { + return self.inner.format_event(ctx, writer, event); + } + + let trace_id = span_context.trace_id(); + let span_id = span_context.span_id(); + + // Events may define fields with the correlation names themselves. In + // that uncommon case, overwrite them rather than emitting duplicate + // JSON keys. Preserve the allocation-free streaming path for ordinary + // events. + let fields = event.metadata().fields(); + if fields.field("trace_id").is_some() || fields.field("span_id").is_some() { + let mut json = String::new(); + self.inner + .format_event(ctx, Writer::new(&mut json), event)?; + let mut object: serde_json::Map = + serde_json::from_str(json.trim_end()).map_err(|_| fmt::Error)?; + object.insert("trace_id".into(), trace_id.to_string().into()); + object.insert("span_id".into(), span_id.to_string().into()); + writer.write_str(&serde_json::to_string(&object).map_err(|_| fmt::Error)?)?; + return writeln!(writer); + } + + let mut writer = CorrelationWriter { + inner: writer, + trace_id, + span_id, + injected: false, + }; + self.inner + .format_event(ctx, Writer::new(&mut writer), event) + } +} /// Build the filter for spans exported through OpenTelemetry. /// @@ -122,8 +270,13 @@ fn classify_exporter_result( #[cfg(test)] mod tests { use super::*; - use opentelemetry::KeyValue; - use std::sync::Mutex; + use opentelemetry::{trace::TracerProvider as _, KeyValue}; + use opentelemetry_sdk::trace::InMemorySpanExporter; + use std::{ + io, + sync::{Arc, Mutex}, + }; + use tracing_subscriber::prelude::*; // Env vars are process-global — serialize tests that mutate them to prevent // cross-test races when the suite runs with multiple threads. @@ -137,6 +290,209 @@ mod tests { .map(|(_, v)| v.to_string()) } + #[derive(Clone)] + struct CapturingWriter(Arc>>); + + impl io::Write for CapturingWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn trace_context_json_correlates_nested_span_logs() { + let output = Arc::new(Mutex::new(Vec::new())); + let output_writer = Arc::clone(&output); + let exporter = InMemorySpanExporter::default(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let tracer = provider.tracer("trace-context-json-test"); + let context_lookup = TraceContextLookup::default(); + + let subscriber = tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .json() + .event_format(context_lookup.json_formatter(true)) + .with_writer(move || CapturingWriter(Arc::clone(&output_writer))) + .with_filter(tracing_subscriber::filter::filter_fn(|metadata| { + metadata.target() != "stdout_filtered" + })), + ) + .with( + tracing_opentelemetry::layer() + .with_tracer(tracer) + .with_filter(tracing_subscriber::filter::filter_fn(|metadata| { + !matches!(metadata.target(), "filtered" | "otel_event_filtered") + })), + ) + .with( + context_lookup + .clone() + .with_filter(tracing_subscriber::filter::LevelFilter::OFF), + ); + + tracing::subscriber::with_default(subscriber, || { + let explicit = tracing::info_span!("explicit"); + let root = tracing::info_span!("root"); + root.in_scope(|| { + tracing::info!(answer = 42, "root event"); + tracing::info!( + trace_id = "event-provided-trace", + span_id = "event-provided-span", + "colliding-fields event" + ); + tracing::info!(parent: &explicit, "explicit-parent event"); + tracing::info!(parent: None, "explicit-root event"); + let child = tracing::info_span!("child"); + child.in_scope(|| tracing::info!("child event")); + + let filtered_child = tracing::info_span!(target: "filtered", "filtered-child"); + filtered_child.in_scope(|| tracing::info!("filtered-child event")); + tracing::info!( + parent: &filtered_child, + "explicit-filtered-child event" + ); + + let stdout_filtered_child = + tracing::info_span!(target: "stdout_filtered", "stdout-filtered-child"); + stdout_filtered_child.in_scope(|| tracing::info!("stdout-filtered-child event")); + + tracing::info!(target: "otel_event_filtered", "otel-filtered event"); + }); + let filtered = tracing::info_span!(target: "filtered", "filtered"); + filtered.in_scope(|| tracing::info!("filtered-span event")); + tracing::info!("unscoped event"); + }); + + provider.force_flush().unwrap(); + let spans = exporter.get_finished_spans().unwrap(); + let root = spans.iter().find(|span| span.name == "root").unwrap(); + let explicit = spans.iter().find(|span| span.name == "explicit").unwrap(); + let child = spans.iter().find(|span| span.name == "child").unwrap(); + let stdout_filtered_child = spans + .iter() + .find(|span| span.name == "stdout-filtered-child") + .unwrap(); + + let bytes = output.lock().unwrap().clone(); + let output = String::from_utf8(bytes).unwrap(); + let lines: Vec<&str> = output.lines().collect(); + let logs: Vec = lines + .iter() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(logs.len(), 11); + + assert_eq!(logs[0]["message"], "root event"); + assert_eq!(logs[0]["answer"], 42); + assert_eq!( + logs[0]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[0]["span_id"], root.span_context.span_id().to_string()); + assert_eq!(logs[0]["trace_id"].as_str().unwrap().len(), 32); + assert_eq!(logs[0]["span_id"].as_str().unwrap().len(), 16); + + assert_eq!(logs[1]["message"], "colliding-fields event"); + assert_eq!( + logs[1]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[1]["span_id"], root.span_context.span_id().to_string()); + assert_eq!(lines[1].matches("\"trace_id\":").count(), 1); + assert_eq!(lines[1].matches("\"span_id\":").count(), 1); + + assert_eq!(logs[2]["message"], "explicit-parent event"); + assert_eq!( + logs[2]["trace_id"], + explicit.span_context.trace_id().to_string() + ); + assert_eq!( + logs[2]["span_id"], + explicit.span_context.span_id().to_string() + ); + + assert_eq!(logs[3]["message"], "explicit-root event"); + assert!(logs[3].get("trace_id").is_none()); + assert!(logs[3].get("span_id").is_none()); + + assert_eq!(logs[4]["message"], "child event"); + assert_eq!( + logs[4]["trace_id"], + child.span_context.trace_id().to_string() + ); + assert_eq!(logs[4]["span_id"], child.span_context.span_id().to_string()); + assert_eq!(logs[0]["trace_id"], logs[4]["trace_id"]); + + assert_eq!(logs[5]["message"], "filtered-child event"); + assert_eq!( + logs[5]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[5]["span_id"], root.span_context.span_id().to_string()); + + assert_eq!(logs[6]["message"], "explicit-filtered-child event"); + assert_eq!( + logs[6]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[6]["span_id"], root.span_context.span_id().to_string()); + + assert_eq!(logs[7]["message"], "stdout-filtered-child event"); + assert_eq!( + logs[7]["trace_id"], + stdout_filtered_child.span_context.trace_id().to_string() + ); + assert_eq!( + logs[7]["span_id"], + stdout_filtered_child.span_context.span_id().to_string() + ); + + assert_eq!(logs[8]["message"], "otel-filtered event"); + assert_eq!( + logs[8]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[8]["span_id"], root.span_context.span_id().to_string()); + + assert_eq!(logs[9]["message"], "filtered-span event"); + assert!(logs[9].get("trace_id").is_none()); + assert!(logs[9].get("span_id").is_none()); + + assert_eq!(logs[10]["message"], "unscoped event"); + assert!(logs[10].get("trace_id").is_none()); + assert!(logs[10].get("span_id").is_none()); + } + + #[test] + fn trace_context_lookup_does_not_enable_callsites() { + let context_lookup = TraceContextLookup::default(); + let subscriber = tracing_subscriber::registry().with( + context_lookup + .clone() + .with_filter(tracing_subscriber::filter::LevelFilter::OFF), + ); + + tracing::subscriber::with_default(subscriber, || { + assert!(context_lookup + .dispatch + .get() + .and_then(tracing::dispatcher::WeakDispatch::upgrade) + .is_some()); + assert!(!tracing::enabled!( + target: "trace_context_lookup_filter_test", + tracing::Level::ERROR + )); + }); + } + #[test] fn test_service_resource_default_when_env_unset() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 8cc9c8650a9..f3f50772934 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -223,6 +223,33 @@ pub fn build_message( mentions: &[&str], broadcast: bool, media_tags: &[Vec], +) -> Result { + build_message_with_extra_tags( + channel_id, + content, + thread_ref, + mentions, + broadcast, + media_tags, + &[], + ) +} + +/// Build a stream message (kind 9) with additional caller-owned tags. +/// +/// This is intended for durable integration identifiers that must survive a +/// process restart (for example an idempotency fence). The normal Buzz tags +/// are still constructed and validated here; every extra tag is parsed by the +/// Nostr library before it is attached. Callers should use a namespaced value +/// and must not use this to replace the canonical `h`, `e`, or `p` tags. +pub fn build_message_with_extra_tags( + channel_id: Uuid, + content: &str, + thread_ref: Option<&ThreadRef>, + mentions: &[&str], + broadcast: bool, + media_tags: &[Vec], + extra_tags: &[Vec], ) -> Result { check_content(content, 64 * 1024)?; let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; @@ -234,6 +261,10 @@ pub fn build_message( tags.push(tag(&["broadcast", "1"])?); } imeta_tags(media_tags, &mut tags)?; + for extra_tag in extra_tags { + let parts: Vec<&str> = extra_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| SdkError::InvalidTag(e.to_string()))?); + } Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } @@ -1895,6 +1926,19 @@ mod tests { assert!(has_tag(&ev, "h", &cid.to_string())); } + #[test] + fn message_with_extra_tags_preserves_durable_fence() { + let cid = uuid(); + let extra = vec![vec![ + "d".to_string(), + "kiingo-publication:fence-123".to_string(), + ]]; + let ev = + sign(build_message_with_extra_tags(cid, "hi", None, &[], false, &[], &extra).unwrap()); + assert!(has_tag(&ev, "d", "kiingo-publication:fence-123")); + assert!(has_tag(&ev, "h", &cid.to_string())); + } + #[test] fn agent_observer_frame_happy_path() { let sender = keys(); diff --git a/crates/kiingo-compute-acp/Cargo.toml b/crates/kiingo-compute-acp/Cargo.toml new file mode 100644 index 00000000000..d6f0eab51c6 --- /dev/null +++ b/crates/kiingo-compute-acp/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "kiingo-compute-acp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "ACP adapter for exact-user Kiingo Compute execution from Buzz" + +[[bin]] +name = "kiingo-compute-acp" +path = "src/main.rs" + +[dependencies] +chrono = { workspace = true } +reqwest = { workspace = true, features = ["query"] } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["io-std", "io-util"] } +tokio-util = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/kiingo-compute-acp/README.md b/crates/kiingo-compute-acp/README.md new file mode 100644 index 00000000000..85cb1546eeb --- /dev/null +++ b/crates/kiingo-compute-acp/README.md @@ -0,0 +1,50 @@ +# kiingo-compute-acp + +`kiingo-compute-acp` is the production ACP adapter between a Buzz agent and +Kiingo Compute. It is intentionally a protocol translator, not an LLM runtime: + +- it accepts ACP v2 `initialize`, `session/new`, `session/prompt`, and + `session/cancel` messages from `buzz-acp`; +- it submits the triggering Buzz event to Kiingo's authenticated canonical + conversation ingress; +- Kiingo resolves the Buzz author on every prompt and selects that exact + employee's ChatGPT-backed Codex connection; +- it replays bounded public Compute events over one pooled HTTP client and + emits ACP updates plus explicit terminal `stopReason` values; +- it sets no provider credential and holds no Buzz private key; +- it requests a fenced publication through ACP, which the parent `buzz-acp` + process signs locally with the agent's Nostr identity. + +The adapter prefers the structured `_meta.buzz` envelope emitted by current +`buzz-acp`. It retains a strict parser for the upstream `format_event_block` +text shape so rolling upgrades remain compatible. + +## Required environment + +| Variable | Purpose | +| --- | --- | +| `KIINGO_API_BASE_URL` | HTTPS origin for Kiingo API. Loopback HTTP is accepted only for local tests. | +| `BUZZ_BRIDGE_INTERNAL_TOKEN` | Narrow bridge service credential. It is never written to ACP output or logs. | +| `BUZZ_COMMUNITY_ID` | Exact community scope used for identity and receipt checks. | +| `BUZZ_AGENT_PUBLIC_KEY` | 64-character public key of the local Buzz signer. | + +Optional bounded tuning: + +- `KIINGO_ACP_POLL_INTERVAL_MS` (default `150`, range `50..5000`) +- `KIINGO_ACP_TURN_TIMEOUT_SECS` (default `1800`, range `30..7200`) + +The parent must explicitly set +`BUZZ_ACP_KIINGO_PUBLICATION_ENABLED=true`. Without that opt-in, +`buzz-acp` discards custom publication updates. The production agent image +target in the repository Dockerfile selects this executable automatically: + +```sh +docker build --target agent-runtime -t buzz-kiingo-agent . +``` + +Run the focused contract tests with: + +```sh +cargo test -p kiingo-compute-acp +``` + diff --git a/crates/kiingo-compute-acp/src/main.rs b/crates/kiingo-compute-acp/src/main.rs new file mode 100644 index 00000000000..b75485c6e1e --- /dev/null +++ b/crates/kiingo-compute-acp/src/main.rs @@ -0,0 +1,1620 @@ +//! ACP v2 adapter that turns a Buzz event envelope into an exact-user, +//! subscription-backed Kiingo Compute conversation turn. +//! +//! The process intentionally owns no provider credential and no Buzz private +//! key. It authenticates only to the narrowly scoped Kiingo bridge API. Buzz +//! publication is requested through a custom ACP update and signed by the +//! parent `buzz-acp` process. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use reqwest::StatusCode; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::sync::{mpsc, Mutex}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const JSON_RPC_VERSION: &str = "2.0"; +const POLL_LIMIT: u64 = 100; +const DEFAULT_POLL_INTERVAL_MS: u64 = 150; +const DEFAULT_TURN_TIMEOUT_SECS: u64 = 1_800; +const MAX_PROMPT_BYTES: usize = 2 * 1024 * 1024; +const LOCAL_ACTION_TIMEOUT_SECS: u64 = 75; +const MAX_LOCAL_ACTION_OUTPUT_BYTES: usize = 128 * 1024; + +type SharedWriter = Arc>; + +#[derive(Clone)] +struct Config { + api_base_url: String, + internal_token: String, + community_id: String, + agent_public_key: String, + poll_interval: Duration, + turn_timeout: Duration, +} + +impl Config { + fn from_env() -> Result { + let api_base_url = required_env("KIINGO_API_BASE_URL")? + .trim_end_matches('/') + .to_string(); + if !(api_base_url.starts_with("https://") + || api_base_url.starts_with("http://127.0.0.1") + || api_base_url.starts_with("http://localhost")) + { + return Err( + "KIINGO_API_BASE_URL must use HTTPS (loopback HTTP is allowed for tests)" + .to_string(), + ); + } + let agent_public_key = required_env("BUZZ_AGENT_PUBLIC_KEY")?.to_ascii_lowercase(); + if !is_hex_id(&agent_public_key) { + return Err("BUZZ_AGENT_PUBLIC_KEY must be a 64-character hex key".to_string()); + } + Ok(Self { + api_base_url, + internal_token: required_env("BUZZ_BRIDGE_INTERNAL_TOKEN")?, + community_id: required_env("BUZZ_COMMUNITY_ID")?, + agent_public_key, + poll_interval: Duration::from_millis(read_u64_env( + "KIINGO_ACP_POLL_INTERVAL_MS", + DEFAULT_POLL_INTERVAL_MS, + 50, + 5_000, + )?), + turn_timeout: Duration::from_secs(read_u64_env( + "KIINGO_ACP_TURN_TIMEOUT_SECS", + DEFAULT_TURN_TIMEOUT_SECS, + 30, + 7_200, + )?), + }) + } +} + +fn required_env(name: &str) -> Result { + let value = std::env::var(name).map_err(|_| format!("{name} is required"))?; + let value = value.trim().to_string(); + if value.is_empty() { + Err(format!("{name} is required")) + } else { + Ok(value) + } +} + +fn read_u64_env(name: &str, default: u64, min: u64, max: u64) -> Result { + let Some(raw) = std::env::var(name).ok() else { + return Ok(default); + }; + let value = raw + .parse::() + .map_err(|_| format!("{name} must be an integer"))?; + if !(min..=max).contains(&value) { + return Err(format!("{name} must be between {min} and {max}")); + } + Ok(value) +} + +fn read_local_buzz_runtime(params: &Value) -> Option { + let servers = params.get("mcpServers")?.as_array()?; + for server in servers { + let Some(command) = server.get("command").and_then(Value::as_str) else { + continue; + }; + let command = command.trim(); + let Some(env_values) = server.get("env").and_then(Value::as_array) else { + continue; + }; + let mut relay_url = None; + let mut private_key = None; + let mut auth_tag = None; + for entry in env_values { + let name = entry.get("name").and_then(Value::as_str).unwrap_or(""); + let value = entry.get("value").and_then(Value::as_str).unwrap_or(""); + match name { + "BUZZ_RELAY_URL" if !value.trim().is_empty() => { + relay_url = Some(value.trim().to_string()) + } + "BUZZ_PRIVATE_KEY" if !value.trim().is_empty() => { + private_key = Some(value.trim().to_string()) + } + "BUZZ_AUTH_TAG" if !value.trim().is_empty() => { + auth_tag = Some(value.trim().to_string()) + } + _ => {} + } + } + let (Some(relay_url), Some(private_key)) = (relay_url, private_key) else { + continue; + }; + let configured = Path::new(command); + let buzz_command = configured + .parent() + .map(|parent| parent.join("buzz")) + .unwrap_or_else(|| PathBuf::from("buzz")); + return Some(LocalBuzzRuntime { + command: buzz_command, + relay_url, + private_key, + auth_tag, + }); + } + None +} + +#[derive(Debug, Clone)] +struct BuzzEnvelope { + event_id: String, + channel_id: String, + channel_name: Option, + author_public_key: String, + authored_at: String, + thread_root_event_id: Option, + text: String, +} + +#[derive(Debug, Clone)] +struct AcceptedTurn { + receipt_id: String, +} + +#[derive(Debug, Clone)] +enum TurnAdmission { + Execution(AcceptedTurn), + ControlCompleted(String), +} + +#[derive(Clone)] +struct LocalBuzzRuntime { + command: PathBuf, + relay_url: String, + private_key: String, + auth_tag: Option, +} + +#[derive(Clone, Default)] +struct SessionState { + local_buzz: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TerminalState { + Completed, + Failed, + Blocked, + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TurnOutcome { + stop_reason: &'static str, + terminal_reason: String, +} + +#[derive(Debug, Clone)] +struct ActivePrompt { + cancellation: CancellationToken, +} + +struct App { + config: Config, + http: reqwest::Client, + writer: SharedWriter, + sessions: HashMap, + active: HashMap, + completed_tx: mpsc::UnboundedSender, +} + +#[tokio::main] +async fn main() { + let config = match Config::from_env() { + Ok(config) => config, + Err(error) => { + eprintln!("kiingo-compute-acp configuration error: {error}"); + std::process::exit(2); + } + }; + let http = match reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(15)) + .pool_idle_timeout(Duration::from_secs(90)) + .build() + { + Ok(http) => http, + Err(error) => { + eprintln!("kiingo-compute-acp HTTP client initialization failed: {error}"); + std::process::exit(2); + } + }; + let writer = Arc::new(Mutex::new(tokio::io::stdout())); + let (completed_tx, mut completed_rx) = mpsc::unbounded_channel(); + let mut app = App { + config, + http, + writer, + sessions: HashMap::new(), + active: HashMap::new(), + completed_tx, + }; + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + loop { + tokio::select! { + line = lines.next_line() => { + match line { + Ok(Some(line)) => handle_line(&mut app, &line).await, + Ok(None) => break, + Err(error) => { + eprintln!("kiingo-compute-acp stdin failed: {error}"); + break; + } + } + } + Some(session_id) = completed_rx.recv() => { + app.active.remove(&session_id); + } + } + } + for prompt in app.active.values() { + prompt.cancellation.cancel(); + } +} + +async fn handle_line(app: &mut App, line: &str) { + if line.len() > MAX_PROMPT_BYTES { + send_error( + &app.writer, + Value::Null, + -32600, + "request exceeds the adapter limit", + ) + .await; + return; + } + let request: Value = match serde_json::from_str(line) { + Ok(request) => request, + Err(_) => { + send_error(&app.writer, Value::Null, -32700, "invalid JSON").await; + return; + } + }; + let method = request.get("method").and_then(Value::as_str).unwrap_or(""); + let id = request.get("id").cloned(); + let params = request.get("params").cloned().unwrap_or(Value::Null); + match method { + "initialize" => { + if let Some(id) = id { + send_result( + &app.writer, + id, + json!({ + "protocolVersion": 2, + "agentCapabilities": { + "loadSession": false, + "promptCapabilities": {"image": false, "audio": false, "embeddedContext": false}, + "mcpCapabilities": {"http": false, "sse": false} + }, + "agentInfo": {"name": "kiingo-compute-acp", "version": env!("CARGO_PKG_VERSION")} + }), + ) + .await; + } + } + "session/new" => { + let Some(id) = id else { return }; + let session_id = format!("kiingo_{}", Uuid::new_v4()); + app.sessions.insert( + session_id.clone(), + SessionState { + local_buzz: read_local_buzz_runtime(¶ms), + }, + ); + send_result(&app.writer, id, json!({"sessionId": session_id})).await; + } + "session/prompt" => { + let Some(id) = id else { return }; + let Some(session_id) = params.get("sessionId").and_then(Value::as_str) else { + send_error(&app.writer, id, -32602, "sessionId is required").await; + return; + }; + let Some(session) = app.sessions.get(session_id).cloned() else { + send_error(&app.writer, id, -32602, "unknown sessionId").await; + return; + }; + if app.active.contains_key(session_id) { + send_error( + &app.writer, + id, + -32000, + "session already has an active prompt", + ) + .await; + return; + } + let envelope = match parse_prompt_envelope(¶ms) { + Ok(envelope) => envelope, + Err(error) => { + send_error(&app.writer, id, -32602, &error).await; + return; + } + }; + let session_id = session_id.to_string(); + let cancellation = CancellationToken::new(); + let receipt_id = Arc::new(Mutex::new(None)); + app.active.insert( + session_id.clone(), + ActivePrompt { + cancellation: cancellation.clone(), + }, + ); + let context = PromptContext { + config: app.config.clone(), + http: app.http.clone(), + writer: Arc::clone(&app.writer), + session_id: session_id.clone(), + request_id: id, + envelope, + local_buzz: session.local_buzz, + cancellation, + receipt_id, + }; + let completed_tx = app.completed_tx.clone(); + tokio::spawn(async move { + run_prompt(context).await; + let _ = completed_tx.send(session_id); + }); + } + "session/cancel" => { + if let Some(session_id) = params.get("sessionId").and_then(Value::as_str) { + if let Some(prompt) = app.active.get(session_id) { + prompt.cancellation.cancel(); + } + } + if let Some(id) = id { + send_result(&app.writer, id, Value::Null).await; + } + } + _ => { + if let Some(id) = id { + send_error(&app.writer, id, -32601, "method not found").await; + } + } + } +} + +struct PromptContext { + config: Config, + http: reqwest::Client, + writer: SharedWriter, + session_id: String, + request_id: Value, + envelope: BuzzEnvelope, + local_buzz: Option, + cancellation: CancellationToken, + receipt_id: Arc>>, +} + +async fn run_prompt(context: PromptContext) { + let outcome = tokio::time::timeout(context.config.turn_timeout, execute_turn(&context)).await; + match outcome { + Ok(Ok(outcome)) => { + send_result( + &context.writer, + context.request_id, + json!({ + "stopReason": outcome.stop_reason, + "_meta": {"kiingo": {"terminal_reason": outcome.terminal_reason}} + }), + ) + .await; + } + Ok(Err(error)) => { + emit_message_chunk( + &context.writer, + &context.session_id, + "Kiingo could not complete this request. The failure was recorded without exposing credentials.", + ) + .await; + send_error(&context.writer, context.request_id, -32000, &error).await; + } + Err(_) => { + if let Some(receipt_id) = context.receipt_id.lock().await.clone() { + let _ = cancel_turn(&context, &receipt_id).await; + let _ = publish_status( + &context, + &receipt_id, + "cancelled", + "timeout", + "This Codex turn reached its time limit and was stopped.", + ) + .await; + } + send_result( + &context.writer, + context.request_id, + json!({ + "stopReason": "cancelled", + "_meta": {"kiingo": {"terminal_reason": "turn_timeout"}} + }), + ) + .await; + } + } +} + +async fn execute_turn(context: &PromptContext) -> Result { + let accepted = match accept_turn(context).await? { + TurnAdmission::Execution(accepted) => accepted, + TurnAdmission::ControlCompleted(message) => { + emit_message_chunk(&context.writer, &context.session_id, &message).await; + return Ok(TurnOutcome { + stop_reason: "end_turn", + terminal_reason: "control_completed".to_string(), + }); + } + }; + *context.receipt_id.lock().await = Some(accepted.receipt_id.clone()); + publish_status( + context, + &accepted.receipt_id, + "receipt", + "accepted", + "Received — starting your Codex session now.", + ) + .await?; + emit_message_chunk( + &context.writer, + &context.session_id, + "Kiingo durably accepted the request.", + ) + .await; + + let mut after_sequence = 0_u64; + let mut last_status: Option = None; + let mut final_text: Option = None; + let mut output_observed = false; + let mut terminal: Option = None; + let mut terminal_reason: Option = None; + loop { + tokio::select! { + _ = context.cancellation.cancelled() => { + cancel_turn(context, &accepted.receipt_id).await?; + publish_status( + context, + &accepted.receipt_id, + "cancelled", + "cancelled", + "Stopped this Codex turn.", + ).await?; + return Ok(TurnOutcome { + stop_reason: "cancelled", + terminal_reason: "user_cancelled_turn".to_string(), + }); + } + _ = tokio::time::sleep(context.config.poll_interval) => {} + } + + process_next_action(context, &accepted.receipt_id).await?; + + let replay = fetch_events(context, &accepted.receipt_id, after_sequence).await?; + after_sequence = replay + .get("next_sequence") + .and_then(Value::as_u64) + .unwrap_or(after_sequence); + if let Some(events) = replay.get("events").and_then(Value::as_array) { + for event in events { + if let Some(text) = assistant_text(event) { + final_text = Some(text.to_string()); + if !output_observed { + emit_message_chunk( + &context.writer, + &context.session_id, + "Codex produced an answer; Buzz is publishing it with the local agent identity.", + ) + .await; + output_observed = true; + } + } + if let Some((status, label)) = activity_status(event) { + if last_status.as_deref() != Some(status) && is_visible_progress(status) { + publish_status( + context, + &accepted.receipt_id, + publication_kind_for_status(status), + &format!("event:{}", event_sequence(event)), + label, + ) + .await?; + last_status = Some(status.to_string()); + } + if terminal.is_none() { + if let Some(state) = terminal_state(event, status) { + terminal_reason = Some(event_terminal_reason(event, state)); + terminal = Some(state); + } + } + } + if event.get("eventType").and_then(Value::as_str) + == Some("executor.dispatch.blocked") + { + if let Some(text) = message_text(event) { + publish_status( + context, + &accepted.receipt_id, + "capacity", + &format!("event:{}", event_sequence(event)), + text, + ) + .await?; + } + if terminal.is_none() { + terminal_reason = + Some(event_terminal_reason(event, TerminalState::Blocked)); + terminal = Some(TerminalState::Blocked); + } + } + } + } + if let Some(state) = terminal { + match state { + TerminalState::Completed => { + let text = final_text + .as_deref() + .unwrap_or("Codex completed the turn without returning a text response."); + publish_status(context, &accepted.receipt_id, "final", "final", text).await?; + return Ok(TurnOutcome { + stop_reason: "end_turn", + terminal_reason: terminal_reason.unwrap_or_else(|| "completed".to_string()), + }); + } + TerminalState::Cancelled => { + return Ok(TurnOutcome { + stop_reason: "cancelled", + terminal_reason: terminal_reason + .unwrap_or_else(|| "user_cancelled_turn".to_string()), + }) + } + TerminalState::Blocked | TerminalState::Failed => { + let text = latest_terminal_text(&replay).unwrap_or_else(|| { + if state == TerminalState::Blocked { + "Codex could not start because ready interactive capacity is unavailable. No cold container was launched.".to_string() + } else { + "The Codex turn failed. Kiingo recorded the failure for recovery.".to_string() + } + }); + publish_status( + context, + &accepted.receipt_id, + if state == TerminalState::Blocked { + "capacity" + } else { + "error" + }, + "terminal", + &text, + ) + .await?; + return Ok(TurnOutcome { + stop_reason: "end_turn", + terminal_reason: terminal_reason.unwrap_or_else(|| { + if state == TerminalState::Blocked { + "capacity_blocked".to_string() + } else { + "provider_failed".to_string() + } + }), + }); + } + } + } + } +} + +async fn accept_turn(context: &PromptContext) -> Result { + let url = format!("{}/api/buzz-bridge/events", context.config.api_base_url); + let response = context + .http + .post(url) + .header("x-kiingo-internal-token", &context.config.internal_token) + .json(&json!({ + "community_id": context.config.community_id, + "agent_public_key": context.config.agent_public_key, + "author_public_key": context.envelope.author_public_key, + "event_id": context.envelope.event_id, + "channel_id": context.envelope.channel_id, + "channel_name": context.envelope.channel_name, + "thread_root_event_id": context.envelope.thread_root_event_id, + "text": context.envelope.text, + "authored_at": context.envelope.authored_at, + "event_metadata": {"source": "buzz_acp_format_event_block", "contract_version": 1} + })) + .send() + .await + .map_err(|error| format!("Kiingo ingress request failed: {error}"))?; + let status = response.status(); + let body: Value = response + .json() + .await + .map_err(|error| format!("Kiingo ingress response was invalid: {error}"))?; + if !status.is_success() { + let code = body + .get("error") + .and_then(Value::as_str) + .unwrap_or("unknown"); + return Err(actionable_ingress_error(status, code)); + } + if body.get("enrollment_completed").and_then(Value::as_bool) == Some(true) { + let message = body + .get("enrollment_message") + .and_then(Value::as_str) + .unwrap_or("Your Buzz identity is linked to Kiingo.") + .to_string(); + let enrollment_url = body.get("enrollment_url").and_then(Value::as_str); + return Ok(TurnAdmission::ControlCompleted(match enrollment_url { + Some(url) + if !body + .get("codex_connected") + .and_then(Value::as_bool) + .unwrap_or(false) => + { + format!("{message} {url}") + } + _ => message, + })); + } + if body.get("control_completed").and_then(Value::as_bool) == Some(true) { + let message = body + .get("control_message") + .and_then(Value::as_str) + .unwrap_or("The Buzz control request was applied.") + .to_string(); + return Ok(TurnAdmission::ControlCompleted(message)); + } + let receipt_id = required_json_string(&body, "receipt_id")?; + required_json_string(&body, "conversation_id")?; + if body.get("selected_harness").and_then(Value::as_str) != Some("codex") + || body.get("cold_fallback").and_then(Value::as_bool) != Some(false) + { + return Err("Kiingo ingress violated the Codex no-cold-start contract".to_string()); + } + Ok(TurnAdmission::Execution(AcceptedTurn { receipt_id })) +} + +fn actionable_ingress_error(status: StatusCode, code: &str) -> String { + match code { + "buzz_identity_not_verified" + | "buzz_identity_ambiguous" + | "buzz_identity_endpoint_not_eligible" + | "buzz_identity_enrollment_invalid" + | "buzz_identity_enrollment_conflict" + | "buzz_codex_subscription_not_connected" + | "buzz_codex_subscription_routing_ambiguous" => format!( + "Buzz identity or Codex access is not active. Link the Buzz public key and connect this user's ChatGPT account at https://app.kiingo.com/team/harness-connections?provider=codex&buzz=connect ({code})." + ), + _ => format!("Kiingo rejected the Buzz event with HTTP {} ({code})", status.as_u16()), + } +} + +async fn fetch_events( + context: &PromptContext, + receipt_id: &str, + after_sequence: u64, +) -> Result { + let url = format!( + "{}/api/buzz-bridge/receipts/{}/events", + context.config.api_base_url, receipt_id + ); + let response = context + .http + .get(url) + .header("x-kiingo-internal-token", &context.config.internal_token) + .query(&[ + ("community_id", context.config.community_id.as_str()), + ("agent_public_key", context.config.agent_public_key.as_str()), + ("after_sequence", &after_sequence.to_string()), + ("limit", &POLL_LIMIT.to_string()), + ]) + .send() + .await + .map_err(|error| format!("Kiingo event replay failed: {error}"))?; + let status = response.status(); + let body: Value = response + .json() + .await + .map_err(|error| format!("Kiingo event replay response was invalid: {error}"))?; + if !status.is_success() { + return Err(format!( + "Kiingo event replay returned HTTP {}", + status.as_u16() + )); + } + Ok(body) +} + +async fn fetch_next_action( + context: &PromptContext, + receipt_id: &str, + worker_id: &str, +) -> Result, String> { + let url = format!( + "{}/api/buzz-bridge/receipts/{}/actions/next", + context.config.api_base_url, receipt_id + ); + let response = context + .http + .get(url) + .header("x-kiingo-internal-token", &context.config.internal_token) + .query(&[ + ("community_id", context.config.community_id.as_str()), + ("agent_public_key", context.config.agent_public_key.as_str()), + ("worker_id", worker_id), + ]) + .send() + .await + .map_err(|error| format!("Buzz action poll failed: {error}"))?; + if response.status() == StatusCode::NO_CONTENT { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!( + "Buzz action poll returned HTTP {}", + response.status().as_u16() + )); + } + response + .json() + .await + .map(Some) + .map_err(|error| format!("Buzz action poll response was invalid: {error}")) +} + +async fn complete_action( + context: &PromptContext, + receipt_id: &str, + action_id: &str, + worker_id: &str, + ok: bool, + result: Value, + error_code: Option<&str>, +) -> Result<(), String> { + let url = format!( + "{}/api/buzz-bridge/receipts/{}/actions/{}/complete", + context.config.api_base_url, receipt_id, action_id + ); + let response = context + .http + .post(url) + .header("x-kiingo-internal-token", &context.config.internal_token) + .json(&json!({ + "community_id": context.config.community_id, + "agent_public_key": context.config.agent_public_key, + "worker_id": worker_id, + "ok": ok, + "result": result, + "error_code": error_code + })) + .send() + .await + .map_err(|error| format!("Buzz action completion failed: {error}"))?; + if response.status().is_success() || response.status() == StatusCode::CONFLICT { + Ok(()) + } else { + Err(format!( + "Buzz action completion returned HTTP {}", + response.status().as_u16() + )) + } +} + +fn action_arguments(action: &Value) -> Value { + action + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})) +} + +fn string_list(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn render_checkpoint(arguments: &Value) -> String { + let summary = arguments + .get("summary") + .and_then(Value::as_str) + .unwrap_or("Task checkpoint recorded."); + let partial = string_list(arguments.get("partial_results")); + let remaining = string_list(arguments.get("remaining_work")); + let mut lines = vec![format!("**Task checkpoint**\n\n{summary}")]; + if !partial.is_empty() { + lines.push(format!( + "**Partial results**\n{}", + partial + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + )); + } + if !remaining.is_empty() { + lines.push(format!( + "**Remaining work**\n{}", + remaining + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + )); + } + lines.join("\n\n") +} + +fn render_approval(action_id: &str, operation: &str, arguments: &Value) -> String { + let reason = arguments + .get("reason") + .and_then(Value::as_str) + .unwrap_or("The agent requested a high-impact Buzz action."); + let argv = arguments.get("argv").cloned().unwrap_or_else(|| json!([])); + format!( + "**Approval required**\n\nOperation: `{operation}`\n\nReason: {reason}\n\nExact arguments: `{}`\n\nApprove: `/kiingo approve {action_id}`\n\nReject: `/kiingo reject {action_id}`", + argv + ) +} + +fn truncate_action_output(bytes: &[u8], runtime: &LocalBuzzRuntime) -> String { + let end = bytes.len().min(MAX_LOCAL_ACTION_OUTPUT_BYTES); + let mut output = String::from_utf8_lossy(&bytes[..end]).to_string(); + output = output.replace(&runtime.private_key, "[REDACTED_BUZZ_PRIVATE_KEY]"); + if let Some(auth_tag) = &runtime.auth_tag { + output = output.replace(auth_tag, "[REDACTED_BUZZ_AUTH_TAG]"); + } + if bytes.len() > end { + output.push_str("\n[output truncated by Kiingo Buzz action bridge]"); + } + output +} + +async fn execute_local_buzz_action( + runtime: Option<&LocalBuzzRuntime>, + arguments: &Value, +) -> (bool, Value, Option<&'static str>) { + let Some(runtime) = runtime else { + return ( + false, + json!({"error": "local_buzz_runtime_unavailable"}), + Some("buzz_action_local_runtime_unavailable"), + ); + }; + let Some(argv_values) = arguments.get("argv").and_then(Value::as_array) else { + return ( + false, + json!({"error": "invalid_action_argv"}), + Some("buzz_action_argv_invalid"), + ); + }; + let mut argv = Vec::with_capacity(argv_values.len()); + for value in argv_values { + let Some(argument) = value.as_str() else { + return ( + false, + json!({"error": "invalid_action_argv"}), + Some("buzz_action_argv_invalid"), + ); + }; + argv.push(argument); + } + let mut command = Command::new(&runtime.command); + command + .args(argv) + .env_clear() + .env("BUZZ_RELAY_URL", &runtime.relay_url) + .env("BUZZ_PRIVATE_KEY", &runtime.private_key) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(auth_tag) = &runtime.auth_tag { + command.env("BUZZ_AUTH_TAG", auth_tag); + } else { + command.env_remove("BUZZ_AUTH_TAG"); + } + match tokio::time::timeout( + Duration::from_secs(LOCAL_ACTION_TIMEOUT_SECS), + command.output(), + ) + .await + { + Ok(Ok(output)) => { + let ok = output.status.success(); + let result = json!({ + "exit_code": output.status.code(), + "stdout": truncate_action_output(&output.stdout, runtime), + "stderr": truncate_action_output(&output.stderr, runtime) + }); + ( + ok, + result, + if ok { + None + } else { + Some("buzz_action_command_failed") + }, + ) + } + Ok(Err(_)) => ( + false, + json!({"error": "local_buzz_command_failed_to_start"}), + Some("buzz_action_command_start_failed"), + ), + Err(_) => ( + false, + json!({"error": "local_buzz_command_timed_out"}), + Some("buzz_action_command_timeout"), + ), + } +} + +async fn process_next_action(context: &PromptContext, receipt_id: &str) -> Result<(), String> { + let worker_id = format!("kiingo-compute-acp:{}", context.session_id); + let Some(action) = fetch_next_action(context, receipt_id, &worker_id).await? else { + return Ok(()); + }; + let action_id = required_json_string(&action, "action_id")?; + let action_kind = required_json_string(&action, "action_kind")?; + let operation = required_json_string(&action, "operation")?; + let arguments = action_arguments(&action); + let outcome = match action_kind.as_str() { + "progress" => { + let message = arguments + .get("message") + .and_then(Value::as_str) + .unwrap_or("Codex reported progress."); + match publish_status( + context, + receipt_id, + "action", + &format!("progress:{action_id}"), + message, + ) + .await + { + Ok(()) => (true, json!({"published": true}), None), + Err(error) => ( + false, + json!({"error": error}), + Some("buzz_action_progress_publish_failed"), + ), + } + } + "complete" => { + let content = render_checkpoint(&arguments); + match publish_status( + context, + receipt_id, + "action", + &format!("completion:{action_id}"), + &content, + ) + .await + { + Ok(()) => (true, json!({"published": true}), None), + Err(error) => ( + false, + json!({"error": error}), + Some("buzz_action_completion_publish_failed"), + ), + } + } + "approval_proposal" => { + let content = render_approval(&action_id, &operation, &arguments); + match publish_status( + context, + receipt_id, + "action", + &format!("approval:{action_id}"), + &content, + ) + .await + { + Ok(()) => ( + true, + json!({ + "proposal_id": action_id, + "approval_command": format!("/kiingo approve {action_id}"), + "rejection_command": format!("/kiingo reject {action_id}"), + "published": true + }), + None, + ), + Err(error) => ( + false, + json!({"error": error}), + Some("buzz_action_approval_publish_failed"), + ), + } + } + "execute" => execute_local_buzz_action(context.local_buzz.as_ref(), &arguments).await, + _ => ( + false, + json!({"error": "unsupported_action_kind"}), + Some("buzz_action_kind_unsupported"), + ), + }; + complete_action( + context, receipt_id, &action_id, &worker_id, outcome.0, outcome.1, outcome.2, + ) + .await +} + +async fn cancel_turn(context: &PromptContext, receipt_id: &str) -> Result<(), String> { + let url = format!( + "{}/api/buzz-bridge/receipts/{}/cancel", + context.config.api_base_url, receipt_id + ); + let response = context + .http + .post(url) + .header("x-kiingo-internal-token", &context.config.internal_token) + .json(&json!({ + "community_id": context.config.community_id, + "agent_public_key": context.config.agent_public_key, + "idempotency_key": format!("buzz-cancel:{receipt_id}") + })) + .send() + .await + .map_err(|error| format!("Kiingo cancellation request failed: {error}"))?; + if response.status().is_success() || response.status() == StatusCode::CONFLICT { + Ok(()) + } else { + Err(format!( + "Kiingo cancellation returned HTTP {}", + response.status().as_u16() + )) + } +} + +async fn publish_status( + context: &PromptContext, + receipt_id: &str, + publication_kind: &str, + suffix: &str, + content: &str, +) -> Result<(), String> { + let idempotency_key = format!("buzz-publication:{receipt_id}:{publication_kind}:{suffix}"); + let payload = json!({ + "channel_id": context.envelope.channel_id, + "thread_root_event_id": context.envelope.thread_root_event_id, + "reply_to_event_id": context.envelope.event_id, + "content": content + }); + let url = format!( + "{}/api/buzz-bridge/receipts/{}/publications/claim", + context.config.api_base_url, receipt_id + ); + let response = context + .http + .post(url) + .header("x-kiingo-internal-token", &context.config.internal_token) + .json(&json!({ + "community_id": context.config.community_id, + "agent_public_key": context.config.agent_public_key, + "idempotency_key": idempotency_key, + "publication_kind": publication_kind, + "payload": payload + })) + .send() + .await + .map_err(|error| format!("publication fence request failed: {error}"))?; + let status = response.status(); + let body: Value = response + .json() + .await + .map_err(|error| format!("publication fence response was invalid: {error}"))?; + if !status.is_success() { + return Err(format!( + "publication fence returned HTTP {}", + status.as_u16() + )); + } + let fence_status = body.get("status").and_then(Value::as_str).unwrap_or(""); + let should_publish = body + .get("should_publish") + .and_then(Value::as_bool) + .unwrap_or(false); + // A `publishing` fence is also emitted for local reconciliation. The + // parent first queries the relay by the fence's durable d-tag; it will not + // publish a second event if the previous process died after submission. + if !should_publish && fence_status != "publishing" { + return Ok(()); + } + let fence_id = required_json_string(&body, "fence_id")?; + emit_publication_intent( + &context.writer, + &context.session_id, + json!({ + "sessionUpdate": "kiingo_buzz_publication", + "community_id": context.config.community_id, + "agent_public_key": context.config.agent_public_key, + "receipt_id": receipt_id, + "fence_id": fence_id, + "channel_id": context.envelope.channel_id, + "thread_root_event_id": context.envelope.thread_root_event_id, + "reply_to_event_id": context.envelope.event_id, + "publication_kind": publication_kind, + "content": content + }), + ) + .await; + Ok(()) +} + +fn parse_prompt_envelope(params: &Value) -> Result { + if let Some(metadata) = params.pointer("/_meta/buzz") { + return parse_structured_buzz_metadata(metadata); + } + let prompt = params + .get("prompt") + .and_then(Value::as_array) + .ok_or_else(|| "prompt content blocks are required".to_string())?; + let event_block = prompt + .iter() + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .filter_map(last_event_segment) + .next_back() + .ok_or_else(|| "prompt does not contain an upstream Buzz event block".to_string())?; + parse_event_block(event_block) +} + +fn parse_structured_buzz_metadata(metadata: &Value) -> Result { + if metadata.get("contractVersion").and_then(Value::as_u64) != Some(1) { + return Err("unsupported structured Buzz metadata contract".to_string()); + } + let event_id = required_json_string(metadata, "eventId")?; + let channel_id = required_json_string(metadata, "channelId")?; + let author_public_key = required_json_string(metadata, "authorPublicKey")?.to_ascii_lowercase(); + let authored_at = required_json_string(metadata, "authoredAt")?; + let text = required_json_string(metadata, "text")?; + if !is_hex_id(&event_id) + || !is_hex_id(&author_public_key) + || Uuid::parse_str(&channel_id).is_err() + || chrono::DateTime::parse_from_rfc3339(&authored_at).is_err() + { + return Err("structured Buzz metadata failed validation".to_string()); + } + let thread_root_event_id = metadata + .get("threadRootEventId") + .and_then(Value::as_str) + .map(str::to_string); + if thread_root_event_id + .as_deref() + .is_some_and(|root| !is_hex_id(root)) + { + return Err("structured Buzz thread root is invalid".to_string()); + } + if metadata.get("replyToEventId").and_then(Value::as_str) != Some(event_id.as_str()) { + return Err("structured Buzz reply target does not match the event".to_string()); + } + Ok(BuzzEnvelope { + event_id, + channel_id: Uuid::parse_str(&channel_id) + .map_err(|_| "structured Buzz channel is invalid".to_string())? + .to_string(), + channel_name: metadata + .get("channelName") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + author_public_key, + authored_at, + thread_root_event_id, + text, + }) +} + +fn last_event_segment(text: &str) -> Option<&str> { + text.rfind("Event ID: ").map(|index| &text[index..]) +} + +fn parse_event_block(block: &str) -> Result { + let event_id = field_line(block, "Event ID: ")?; + if !is_hex_id(&event_id) { + return Err("event ID is not a 64-character hex value".to_string()); + } + let channel_line = field_line(block, "Channel: ")?; + let channel_id = extract_uuid(&channel_line) + .ok_or_else(|| "channel field does not contain a UUID".to_string())?; + let channel_name = channel_line + .split_once(" (#") + .map(|(name, _)| name.trim().to_string()) + .filter(|name| !name.is_empty()); + let from = field_line(block, "From: ")?; + let author_public_key = extract_author_hex(&from) + .ok_or_else(|| "sender field does not contain a 64-character hex key".to_string())?; + let authored_at = field_line(block, "Time: ")?; + chrono::DateTime::parse_from_rfc3339(&authored_at) + .map_err(|_| "event time is not RFC3339".to_string())?; + let content_start = block + .find("\nContent: ") + .map(|index| index + "\nContent: ".len()) + .ok_or_else(|| "event content field is missing".to_string())?; + let after_content = &block[content_start..]; + let content_end = after_content + .rfind("\nTags: ") + .ok_or_else(|| "event tags boundary is missing".to_string())?; + let text = after_content[..content_end].to_string(); + if text.trim().is_empty() { + return Err("event content is empty".to_string()); + } + let thread_root_event_id = block + .lines() + .find_map(|line| line.strip_prefix("Parsed: ")) + .and_then(|parsed| { + parsed.split(',').find_map(|part| { + part.trim() + .strip_prefix("root=") + .filter(|value| is_hex_id(value)) + .map(str::to_string) + }) + }); + Ok(BuzzEnvelope { + event_id, + channel_id, + channel_name, + author_public_key, + authored_at, + thread_root_event_id, + text, + }) +} + +fn field_line(block: &str, prefix: &str) -> Result { + block + .lines() + .find_map(|line| line.strip_prefix(prefix)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("event field {prefix:?} is missing")) +} + +fn extract_uuid(value: &str) -> Option { + value + .split(|character: char| !(character.is_ascii_hexdigit() || character == '-')) + .find_map(|candidate| Uuid::parse_str(candidate).ok().map(|id| id.to_string())) +} + +fn extract_author_hex(value: &str) -> Option { + let marker = "hex: "; + let start = value.rfind(marker)? + marker.len(); + let candidate: String = value[start..] + .chars() + .take_while(|character| character.is_ascii_hexdigit()) + .take(64) + .collect(); + is_hex_id(&candidate).then(|| candidate.to_ascii_lowercase()) +} + +fn is_hex_id(value: &str) -> bool { + value.len() == 64 && value.chars().all(|character| character.is_ascii_hexdigit()) +} + +fn assistant_text(event: &Value) -> Option<&str> { + (event.get("kind").and_then(Value::as_str) == Some("message") + && event.pointer("/payload/role").and_then(Value::as_str) == Some("assistant")) + .then(|| event.pointer("/payload/text").and_then(Value::as_str)) + .flatten() + .filter(|text| !text.trim().is_empty()) +} + +fn message_text(event: &Value) -> Option<&str> { + (event.get("kind").and_then(Value::as_str) == Some("message")) + .then(|| event.pointer("/payload/text").and_then(Value::as_str)) + .flatten() + .filter(|text| !text.trim().is_empty()) +} + +fn activity_status(event: &Value) -> Option<(&str, &str)> { + if event.get("kind").and_then(Value::as_str) != Some("activity") { + return None; + } + Some(( + event.pointer("/payload/status")?.as_str()?, + event.pointer("/payload/label")?.as_str()?, + )) +} + +fn terminal_state(event: &Value, status: &str) -> Option { + match (event.get("eventType").and_then(Value::as_str), status) { + (Some("executor.dispatch.completed"), "completed") => Some(TerminalState::Completed), + (Some("executor.dispatch.failed" | "turn.execution.dead_lettered"), _) => { + Some(TerminalState::Failed) + } + (Some("executor.dispatch.blocked"), _) => Some(TerminalState::Blocked), + (Some("executor.dispatch.cancelled"), _) => Some(TerminalState::Cancelled), + _ => None, + } +} + +fn event_terminal_reason(event: &Value, state: TerminalState) -> String { + event + .pointer("/payload/metadata/reason") + .and_then(Value::as_str) + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| match state { + TerminalState::Completed => "completed".to_string(), + TerminalState::Failed => "provider_failed".to_string(), + TerminalState::Blocked => "capacity_blocked".to_string(), + TerminalState::Cancelled => "user_cancelled_turn".to_string(), + }) +} + +fn is_visible_progress(status: &str) -> bool { + matches!( + status, + "queued" | "queued_behind" | "starting" | "working" | "recovery_scheduled" + ) +} + +fn publication_kind_for_status(status: &str) -> &'static str { + if matches!(status, "queued" | "queued_behind") { + "capacity" + } else { + "progress" + } +} + +fn event_sequence(event: &Value) -> u64 { + event.get("sequence").and_then(Value::as_u64).unwrap_or(0) +} + +fn latest_terminal_text(replay: &Value) -> Option { + replay + .get("events") + .and_then(Value::as_array)? + .iter() + .rev() + .find_map(message_text) + .map(str::to_string) +} + +fn required_json_string(value: &Value, key: &str) -> Result { + value + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("Kiingo response is missing {key}")) +} + +async fn emit_message_chunk(writer: &SharedWriter, session_id: &str, text: &str) { + send_value( + writer, + json!({ + "jsonrpc": JSON_RPC_VERSION, + "method": "session/update", + "params": { + "sessionId": session_id, + "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}} + } + }), + ) + .await; +} + +async fn emit_publication_intent(writer: &SharedWriter, session_id: &str, update: Value) { + send_value( + writer, + json!({ + "jsonrpc": JSON_RPC_VERSION, + "method": "session/update", + "params": {"sessionId": session_id, "update": update} + }), + ) + .await; +} + +async fn send_result(writer: &SharedWriter, id: Value, result: Value) { + send_value( + writer, + json!({"jsonrpc": JSON_RPC_VERSION, "id": id, "result": result}), + ) + .await; +} + +async fn send_error(writer: &SharedWriter, id: Value, code: i64, message: &str) { + send_value( + writer, + json!({"jsonrpc": JSON_RPC_VERSION, "id": id, "error": {"code": code, "message": message}}), + ) + .await; +} + +async fn send_value(writer: &SharedWriter, value: Value) { + let Ok(mut line) = serde_json::to_vec(&value) else { + return; + }; + line.push(b'\n'); + let mut writer = writer.lock().await; + if writer.write_all(&line).await.is_ok() { + let _ = writer.flush().await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const ROOT_ID: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const AUTHOR: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + fn fixture_event_block() -> String { + format!( + "[Buzz event: mentioned]\nEvent ID: {EVENT_ID}\nChannel: general (#4d3f6928-9bf5-43f7-87e4-55f4eeadcb03)\nKind: 9\nFrom: Ross (npub: npub1example, hex: {AUTHOR})\nTime: 2026-07-29T17:03:02+00:00\nContent: Please investigate this.\nIt can span lines.\nTags: [[\"h\",\"4d3f6928-9bf5-43f7-87e4-55f4eeadcb03\"],[\"e\",\"{ROOT_ID}\",\"\",\"root\"]]\nParsed: parent={EVENT_ID}, root={ROOT_ID}" + ) + } + + #[test] + fn parses_upstream_format_event_block_fixture() { + let params = json!({ + "sessionId": "session", + "prompt": [ + {"type": "text", "text": "[Context]\nScope: thread"}, + {"type": "text", "text": fixture_event_block()} + ] + }); + let parsed = parse_prompt_envelope(¶ms).expect("fixture should parse"); + assert_eq!(parsed.event_id, EVENT_ID); + assert_eq!(parsed.author_public_key, AUTHOR); + assert_eq!(parsed.channel_id, "4d3f6928-9bf5-43f7-87e4-55f4eeadcb03"); + assert_eq!(parsed.channel_name.as_deref(), Some("general")); + assert_eq!(parsed.thread_root_event_id.as_deref(), Some(ROOT_ID)); + assert_eq!(parsed.text, "Please investigate this.\nIt can span lines."); + } + + #[test] + fn prefers_structured_upstream_metadata() { + let params = json!({ + "_meta": {"buzz": { + "contractVersion": 1, + "eventId": EVENT_ID, + "channelId": "4d3f6928-9bf5-43f7-87e4-55f4eeadcb03", + "channelName": "general", + "authorPublicKey": AUTHOR, + "authoredAt": "2026-07-29T17:03:02+00:00", + "text": "Structured request", + "threadRootEventId": ROOT_ID, + "replyToEventId": EVENT_ID + }}, + "prompt": [{"type": "text", "text": "this formatter can change"}] + }); + let parsed = parse_prompt_envelope(¶ms).expect("metadata should parse"); + assert_eq!(parsed.event_id, EVENT_ID); + assert_eq!(parsed.text, "Structured request"); + assert_eq!(parsed.thread_root_event_id.as_deref(), Some(ROOT_ID)); + } + + #[test] + fn uses_last_event_in_a_batched_upstream_block() { + let first = fixture_event_block(); + let second = first.replace(EVENT_ID, ROOT_ID).replace( + "Please investigate this.\nIt can span lines.", + "Latest request", + ); + let params = json!({ + "prompt": [{"type": "text", "text": format!("[Buzz events — 2 events]\n\n--- Event 1 ---\n{first}\n\n--- Event 2 ---\n{second}")}] + }); + let parsed = parse_prompt_envelope(¶ms).expect("batch should parse"); + assert_eq!(parsed.event_id, ROOT_ID); + assert_eq!(parsed.text, "Latest request"); + } + + #[test] + fn rejects_non_event_prompt_content() { + let error = parse_prompt_envelope(&json!({ + "prompt": [{"type": "text", "text": "ordinary prompt"}] + })) + .expect_err("ordinary text must not cross the bridge"); + assert!(error.contains("upstream Buzz event block")); + } + + #[test] + fn provides_actionable_enrollment_guidance() { + let error = actionable_ingress_error(StatusCode::FORBIDDEN, "buzz_identity_not_verified"); + assert!(error.contains("/team/harness-connections?provider=codex")); + } + + #[test] + fn extracts_only_the_local_buzz_runtime_from_acp_mcp_config() { + let runtime = read_local_buzz_runtime(&json!({ + "mcpServers": [{ + "name": "buzz-dev-mcp", + "command": "/usr/local/bin/buzz-dev-mcp", + "args": [], + "env": [ + {"name": "BUZZ_RELAY_URL", "value": "wss://chat.kiingo.com"}, + {"name": "BUZZ_PRIVATE_KEY", "value": "nsec_test_secret"}, + {"name": "UNRELATED_SECRET", "value": "must-not-be-forwarded"} + ] + }] + })) + .expect("local runtime"); + assert_eq!(runtime.command, PathBuf::from("/usr/local/bin/buzz")); + assert_eq!(runtime.relay_url, "wss://chat.kiingo.com"); + assert_eq!(runtime.private_key, "nsec_test_secret"); + assert!(runtime.auth_tag.is_none()); + } + + #[test] + fn renders_explicit_partial_completion_and_signed_approval_commands() { + let checkpoint = render_checkpoint(&json!({ + "summary": "Completed the safe portion.", + "partial_results": ["Created the draft"], + "remaining_work": ["Wait for approval"] + })); + assert!(checkpoint.contains("**Partial results**")); + assert!(checkpoint.contains("**Remaining work**")); + + let proposal_id = "11111111-1111-4111-8111-111111111111"; + let approval = render_approval( + proposal_id, + "channels.delete", + &json!({"reason": "Requested cleanup", "argv": ["channels", "delete", "abc"]}), + ); + assert!(approval.contains(&format!("/kiingo approve {proposal_id}"))); + assert!(approval.contains(&format!("/kiingo reject {proposal_id}"))); + } + + #[test] + fn redacts_local_signing_material_from_action_results() { + let runtime = LocalBuzzRuntime { + command: PathBuf::from("/usr/local/bin/buzz"), + relay_url: "wss://chat.kiingo.com".to_string(), + private_key: "nsec_test_secret".to_string(), + auth_tag: Some("auth-tag-secret".to_string()), + }; + let output = + truncate_action_output(b"failed nsec_test_secret with auth-tag-secret", &runtime); + assert!(!output.contains("nsec_test_secret")); + assert!(!output.contains("auth-tag-secret")); + assert!(output.contains("[REDACTED_BUZZ_PRIVATE_KEY]")); + assert!(output.contains("[REDACTED_BUZZ_AUTH_TAG]")); + } + + #[test] + fn preserves_provider_and_worker_terminal_reasons_from_public_events() { + let event = json!({ + "eventType": "executor.dispatch.failed", + "kind": "activity", + "payload": { + "status": "failed", + "metadata": {"reason": "pool_parent_forced_drain"} + } + }); + assert_eq!( + event_terminal_reason(&event, TerminalState::Failed), + "pool_parent_forced_drain" + ); + assert_eq!( + event_terminal_reason(&json!({}), TerminalState::Blocked), + "capacity_blocked" + ); + } +} diff --git a/deploy/azure/Dockerfile.storage-conformance b/deploy/azure/Dockerfile.storage-conformance new file mode 100644 index 00000000000..e6734fbf84c --- /dev/null +++ b/deploy/azure/Dockerfile.storage-conformance @@ -0,0 +1,26 @@ +# syntax=docker/dockerfile:1.7 + +ARG RUST_VERSION=1.95 +ARG DEBIAN_VERSION=bookworm + +FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS builder +WORKDIR /build +COPY . . +RUN cargo test --release --locked -p buzz-azure-storage \ + --test azurite_conformance --no-run \ + && test_binary="$(find target/release/deps -maxdepth 1 \ + -type f -name 'azurite_conformance-*' -perm /111 | head -1)" \ + && test -n "${test_binary}" \ + && install -D -m 0755 "${test_binary}" /out/buzz-azure-conformance + +FROM debian:${DEBIAN_VERSION}-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1000 buzz \ + && useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz \ + --create-home --shell /usr/sbin/nologin buzz +COPY --from=builder /out/buzz-azure-conformance /usr/local/bin/buzz-azure-conformance +USER buzz:buzz +WORKDIR /var/lib/buzz +ENTRYPOINT ["/usr/local/bin/buzz-azure-conformance", "--nocapture"] diff --git a/deploy/azure/README.md b/deploy/azure/README.md new file mode 100644 index 00000000000..e49e205b1ba --- /dev/null +++ b/deploy/azure/README.md @@ -0,0 +1,57 @@ +# Azure production deployment + +This directory is the production-only AKS packaging for the Kiingo Buzz +community. It keeps PostgreSQL and Blob state outside the cluster, uses Azure +workload identity for Blob and Key Vault, and treats in-cluster Redis as +disposable coordination state. + +The checked-in YAML contains placeholder tokens. The deployment workflow +renders them into a temporary directory, archives the rendered chart and +manifests, and sends that archive to the private AKS cluster with Azure Run +Command. Rendered files are never committed or uploaded as artifacts. +The relay owner key and Front Door verification secret are deliberately left +unrendered by GitHub: the database bootstrap's CSI mount synchronizes them from +the private Key Vault, then `deploy.sh` validates and renders them only inside +the private command environment. + +Before installing the chart, render `secret-provider-class.yaml` with the relay +identity, vault name, and subscription tenant, then run +`key-vault-conformance-job.yaml`. The restricted Job mounts every runtime value +through the production workload identity, verifies all 13 files are non-empty, +and checks only the public owner-key and origin-secret formats. It never prints +secret content. A successful run also proves that the `buzz-runtime` Kubernetes +Secret was synchronized for the deployment bootstrap. + +Deployment order is intentional: + +1. create the namespace, workload-identity service account, and Key Vault CSI + projection; +2. install digest-pinned cert-manager and ingress-nginx charts; +3. create or rotate the least-privilege Buzz database role and run migrations; +4. install the relay and disposable Redis with the local chart; +5. register the local-signing agent identity and start one 12-worker listener; +6. verify Kubernetes readiness before Front Door cutover. + +Before the first relay deployment, build +`Dockerfile.storage-conformance`, pin the resulting digest in +`storage-conformance-job.yaml`, and run that Job with the same `buzz-relay` +workload identity. It executes the reviewed adapter contract against the +private `buzz-conformance` container without a storage key. The Job is +disposable and is not part of steady-state production. + +After conformance, render and run `storage-recovery-job.yaml` through the same +private-cluster command path. It uses the same workload identity to create two +versions, restore the first version, delete the current logical blob, and +reconstruct it from the retained version. The Azure CLI image must be pinned to +the reviewed platform digest; no storage account key or SAS is used. + +`__BUZZ_USER_PUBKEY_ALLOWLIST__` is mandatory and must render to a non-empty, +comma-separated list of approved 64-character Nostr public keys. The same list +is used for relay membership and the ACP author gate. Production permits only +`owner-only` or `allowlist` response modes; `anyone` cannot be selected through +runtime configuration. + +The ingress load balancer accepts only the Azure Front Door backend service +tag. NGINX additionally validates the exact Front Door resource ID and a +deployment-specific origin header. The relay sees chat.kiingo.com as the Host +even through the preview domain, preserving Buzz's fail-closed tenant binding. diff --git a/deploy/azure/agent.yaml b/deploy/azure/agent.yaml new file mode 100644 index 00000000000..bb090df6bab --- /dev/null +++ b/deploy/azure/agent.yaml @@ -0,0 +1,176 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: buzz-agent-membership + namespace: buzz +spec: + backoffLimit: 3 + activeDeadlineSeconds: 300 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/part-of: buzz + app.kubernetes.io/component: agent-membership + spec: + restartPolicy: OnFailure + serviceAccountName: buzz-relay + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: register + image: __RELAY_IMAGE_REPOSITORY__@__RELAY_IMAGE_DIGEST__ + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-c"] + args: + - | + set -eu + /usr/local/bin/buzz-admin add-member --pubkey "$BUZZ_AGENT_PUBLIC_KEY" --role member + old_ifs="$IFS" + IFS=',' + for pubkey in $BUZZ_USER_PUBKEY_ALLOWLIST; do + case "$pubkey" in + *[!0-9a-fA-F]*|'') echo "invalid Buzz user public key" >&2; exit 1 ;; + esac + if [ "${#pubkey}" -ne 64 ]; then + echo "Buzz user public keys must contain exactly 64 hexadecimal characters" >&2 + exit 1 + fi + /usr/local/bin/buzz-admin add-member --pubkey "$pubkey" --role member + done + IFS="$old_ifs" + envFrom: + - secretRef: + name: buzz-runtime + env: + - name: BUZZ_USER_PUBKEY_ALLOWLIST + value: __BUZZ_USER_PUBKEY_ALLOWLIST__ + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: buzz-kiingo-agent + namespace: buzz + labels: + app.kubernetes.io/name: buzz-kiingo-agent + app.kubernetes.io/part-of: buzz +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: buzz-kiingo-agent + template: + metadata: + labels: + app.kubernetes.io/name: buzz-kiingo-agent + app.kubernetes.io/part-of: buzz + azure.workload.identity/use: "true" + spec: + serviceAccountName: buzz-relay + terminationGracePeriodSeconds: 30 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: agent + image: __AGENT_IMAGE_REPOSITORY__@__AGENT_IMAGE_DIGEST__ + imagePullPolicy: IfNotPresent + env: + - name: BUZZ_RELAY_URL + value: wss://buzz-preview.kiingo.com + - name: BUZZ_COMMUNITY_ID + value: chat.kiingo.com + - name: BUZZ_AGENT_PUBLIC_KEY + valueFrom: + secretKeyRef: + name: buzz-runtime + key: BUZZ_AGENT_PUBLIC_KEY + - name: BUZZ_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: buzz-runtime + key: BUZZ_PRIVATE_KEY + - name: BUZZ_BRIDGE_INTERNAL_TOKEN + valueFrom: + secretKeyRef: + name: buzz-runtime + key: BUZZ_BRIDGE_INTERNAL_TOKEN + - name: KIINGO_API_BASE_URL + value: https://api.kiingo.com + - name: BUZZ_ACP_AGENTS + value: "12" + - name: BUZZ_ACP_RESPOND_TO + value: allowlist + - name: BUZZ_ACP_RESPOND_TO_ALLOWLIST + value: __BUZZ_USER_PUBKEY_ALLOWLIST__ + - name: BUZZ_ACP_ALLOWED_RESPOND_TO + value: owner-only,allowlist + - name: BUZZ_ACP_KIINGO_PUBLICATION_ENABLED + value: "true" + - name: KIINGO_ACP_POLL_INTERVAL_MS + value: "100" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + readinessProbe: + exec: + command: ["/bin/sh", "-c", "kill -0 1"] + initialDelaySeconds: 3 + periodSeconds: 5 + livenessProbe: + exec: + command: ["/bin/sh", "-c", "kill -0 1"] + initialDelaySeconds: 10 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + volumeMounts: + - name: secrets + mountPath: /mnt/secrets-store + readOnly: true + volumes: + - name: secrets + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: buzz-runtime +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: buzz-kiingo-agent + namespace: buzz +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: buzz-kiingo-agent + policyTypes: [Ingress] + ingress: [] diff --git a/deploy/azure/cert-manager-values.yaml b/deploy/azure/cert-manager-values.yaml new file mode 100644 index 00000000000..61ec078e823 --- /dev/null +++ b/deploy/azure/cert-manager-values.yaml @@ -0,0 +1,18 @@ +crds: + enabled: true +serviceAccount: + annotations: + azure.workload.identity/client-id: __CERT_MANAGER_IDENTITY_CLIENT_ID__ +podLabels: + azure.workload.identity/use: "true" +image: + digest: sha256:416a2d76870d996460e62bd7f521bf14fa017be9e3e904aab92163a331fcb61a +cainjector: + image: + digest: sha256:ccf6b919ec0500745a47a910118f834f9636d0aac1ff221245cd2557ed8c7c98 +webhook: + image: + digest: sha256:d8b3961b51c8c7320633f8208dc46bf88aa13804d0f7cbe48a096b2c523cee42 +startupapicheck: + image: + digest: sha256:d8ab6416e6e7303a86fa0a8daa82c94a8001f21c9d78eb2e7db20534e5d07ae8 diff --git a/deploy/azure/certificates.yaml b/deploy/azure/certificates.yaml new file mode 100644 index 00000000000..873d8a1a1b6 --- /dev/null +++ b/deploy/azure/certificates.yaml @@ -0,0 +1,35 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-buzz-prod +spec: + acme: + email: ross@kiingo.com + server: https://acme-v02.api.letsencrypt.org/directory + privateKeySecretRef: + name: letsencrypt-buzz-prod-account + solvers: + - dns01: + azureDNS: + subscriptionID: __AZURE_SUBSCRIPTION_ID__ + resourceGroupName: rg-kiingo-website + hostedZoneName: kiingo.com + environment: AzurePublicCloud + managedIdentity: + clientID: __CERT_MANAGER_IDENTITY_CLIENT_ID__ +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: buzz-origin + namespace: buzz +spec: + secretName: buzz-origin-tls + issuerRef: + name: letsencrypt-buzz-prod + kind: ClusterIssuer + dnsNames: + - buzz-origin.kiingo.com + privateKey: + rotationPolicy: Always + renewBefore: 360h diff --git a/deploy/azure/database-jobs.yaml b/deploy/azure/database-jobs.yaml new file mode 100644 index 00000000000..1c30bbf2300 --- /dev/null +++ b/deploy/azure/database-jobs.yaml @@ -0,0 +1,116 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: buzz-database-bootstrap + namespace: buzz +spec: + backoffLimit: 3 + activeDeadlineSeconds: 600 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/part-of: buzz + app.kubernetes.io/component: database-bootstrap + azure.workload.identity/use: "true" + spec: + restartPolicy: OnFailure + serviceAccountName: buzz-relay + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: bootstrap + image: __RELAY_IMAGE_REPOSITORY__@__RELAY_IMAGE_DIGEST__ + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-c"] + args: + - | + set -eu + export PGPASSWORD="$(cat /mnt/secrets-store/buzz-postgres-admin-password)" + app_password="$(cat /mnt/secrets-store/buzz-database-password)" + postgres_host="$(cat /mnt/secrets-store/buzz-postgres-host)" + psql "host=$postgres_host port=5432 dbname=buzz user=buzzadmin sslmode=require" \ + --set=ON_ERROR_STOP=1 --set=app_password="$app_password" <<'SQL' + DO $$ + BEGIN + CREATE ROLE buzz_app LOGIN; + EXCEPTION + WHEN duplicate_object THEN NULL; + END + $$; + SELECT format('ALTER ROLE buzz_app LOGIN PASSWORD %L', :'app_password') \gexec + ALTER DATABASE buzz OWNER TO buzz_app; + SQL + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + volumeMounts: + - name: secrets + mountPath: /mnt/secrets-store + readOnly: true + volumes: + - name: secrets + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: buzz-runtime +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: buzz-database-migrate + namespace: buzz +spec: + backoffLimit: 3 + activeDeadlineSeconds: 600 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/part-of: buzz + app.kubernetes.io/component: database-migrate + spec: + restartPolicy: OnFailure + serviceAccountName: buzz-relay + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: migrate + image: __RELAY_IMAGE_REPOSITORY__@__RELAY_IMAGE_DIGEST__ + imagePullPolicy: IfNotPresent + command: ["/usr/local/bin/buzz-admin", "migrate"] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: buzz-runtime + key: DATABASE_URL + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] diff --git a/deploy/azure/deploy.sh b/deploy/azure/deploy.sh new file mode 100644 index 00000000000..26705cb7553 --- /dev/null +++ b/deploy/azure/deploy.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +for file in namespace.yaml secret-provider-class.yaml ingress-values.yaml cert-manager-values.yaml certificates.yaml database-jobs.yaml agent.yaml; do + if grep -q '__[A-Z0-9_][A-Z0-9_]*__' "$file"; then + echo "unrendered deployment token in $file" >&2 + exit 1 + fi +done + +kubectl apply -f namespace.yaml +kubectl apply -f secret-provider-class.yaml +helm repo add jetstack https://charts.jetstack.io +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx +helm repo update +helm upgrade --install cert-manager jetstack/cert-manager --version v1.21.1 --namespace cert-manager --create-namespace --values cert-manager-values.yaml --atomic --wait --timeout 10m +kubectl apply -f certificates.yaml +helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx --version 4.15.1 --namespace ingress-nginx --create-namespace --values ingress-values.yaml --atomic --wait --timeout 10m +kubectl -n buzz delete job buzz-database-bootstrap buzz-database-migrate --ignore-not-found +kubectl apply -f database-jobs.yaml +kubectl -n buzz wait --for=condition=complete job/buzz-database-bootstrap --timeout=10m +kubectl -n buzz wait --for=condition=complete job/buzz-database-migrate --timeout=10m + +relay_owner_pubkey="$(kubectl -n buzz get secret buzz-runtime -o jsonpath='{.data.RELAY_OWNER_PUBKEY}' | base64 -d)" +origin_verification_secret="$(kubectl -n buzz get secret buzz-runtime -o jsonpath='{.data.BUZZ_FRONT_DOOR_ORIGIN_SECRET}' | base64 -d)" +if [[ ! "${relay_owner_pubkey}" =~ ^[a-f0-9]{64}$ ]]; then + echo 'Key Vault relay owner public key is not a 64-character lowercase hexadecimal key.' >&2 + exit 1 +fi +if [[ ! "${origin_verification_secret}" =~ ^[A-Za-z0-9_-]{32,128}$ ]]; then + echo 'Key Vault origin verification secret does not satisfy the header-safe contract.' >&2 + exit 1 +fi +RELAY_OWNER_PUBKEY="${relay_owner_pubkey}" \ +ORIGIN_VERIFICATION_SECRET="${origin_verification_secret}" \ +python3 - <<'PY' +import os +from pathlib import Path + +replacements = { + "__RELAY_OWNER_PUBKEY__": os.environ["RELAY_OWNER_PUBKEY"], + "__ORIGIN_VERIFICATION_SECRET__": os.environ["ORIGIN_VERIFICATION_SECRET"], +} +for name in ("prod-values.yaml", "health-ingress.yaml"): + path = Path(name) + text = path.read_text(encoding="utf-8") + for token, value in replacements.items(): + text = text.replace(token, value) + path.write_text(text, encoding="utf-8") +PY +unset relay_owner_pubkey origin_verification_secret RELAY_OWNER_PUBKEY ORIGIN_VERIFICATION_SECRET +for file in prod-values.yaml health-ingress.yaml; do + if grep -q '__[A-Z0-9_][A-Z0-9_]*__' "$file"; then + echo "unrendered deployment token in $file" >&2 + exit 1 + fi +done + +helm upgrade --install buzz ./chart --namespace buzz --values prod-values.yaml --atomic --wait --timeout 15m +kubectl apply -f health-ingress.yaml +kubectl -n buzz delete job buzz-agent-membership --ignore-not-found +kubectl apply -f agent.yaml +kubectl -n buzz wait --for=condition=complete job/buzz-agent-membership --timeout=5m +kubectl -n buzz rollout status deployment/buzz-kiingo-agent --timeout=10m +kubectl -n buzz wait --for=condition=Ready certificate/buzz-origin --timeout=10m +kubectl -n buzz get deployment,statefulset,pod,job,ingress,certificate diff --git a/deploy/azure/health-ingress.yaml b/deploy/azure/health-ingress.yaml new file mode 100644 index 00000000000..062985aff00 --- /dev/null +++ b/deploy/azure/health-ingress.yaml @@ -0,0 +1,29 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: buzz-health + namespace: buzz + annotations: + nginx.ingress.kubernetes.io/enable-modsecurity: "true" + nginx.ingress.kubernetes.io/modsecurity-snippet: | + SecRuleEngine On + SecRule &REQUEST_HEADERS:X-Azure-FDID "@eq 0" "id:206,phase:1,deny,status:403,log,msg:'Front Door ID missing'" + SecRule REQUEST_HEADERS:X-Azure-FDID "!@streq __FRONT_DOOR_ID__" "id:207,phase:1,deny,status:403,log,msg:'Front Door ID invalid'" + SecRule REQUEST_HEADERS:X-Buzz-Origin-Verify "!@streq __ORIGIN_VERIFICATION_SECRET__" "id:208,phase:1,deny,status:403,log,msg:'Origin verification invalid'" +spec: + ingressClassName: nginx + tls: + - hosts: + - buzz-origin.kiingo.com + secretName: buzz-origin-tls + rules: + - host: chat.kiingo.com + http: + paths: + - path: /_readiness + pathType: Exact + backend: + service: + name: buzz + port: + name: health diff --git a/deploy/azure/ingress-values.yaml b/deploy/azure/ingress-values.yaml new file mode 100644 index 00000000000..f952d6661c0 --- /dev/null +++ b/deploy/azure/ingress-values.yaml @@ -0,0 +1,32 @@ +controller: + replicaCount: 2 + allowSnippetAnnotations: true + config: + enable-modsecurity: "true" + use-forwarded-headers: "true" + proxy-real-ip-cidr: "147.243.0.0/16" + service: + externalTrafficPolicy: Local + annotations: + service.beta.kubernetes.io/azure-load-balancer-resource-group: rg-buzz-prod + service.beta.kubernetes.io/azure-pip-name: pip-buzz-origin-prod + service.beta.kubernetes.io/azure-allowed-service-tags: AzureFrontDoor.Backend + service.beta.kubernetes.io/azure-load-balancer-tcp-idle-timeout: "30" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + podDisruptionBudget: + enabled: true + minAvailable: 1 + admissionWebhooks: + patch: + image: + digest: sha256:01038e7de14b78d702d2849c3aad72fd25903c4765af63cf16aa3398f5d5f2dd + image: + digest: sha256:594ceea76b01c592858f803f9ff4d2cb40542cae2060410b2c95f75907d659e1 +defaultBackend: + enabled: false diff --git a/deploy/azure/key-vault-conformance-job.yaml b/deploy/azure/key-vault-conformance-job.yaml new file mode 100644 index 00000000000..6f6eb5aaa38 --- /dev/null +++ b/deploy/azure/key-vault-conformance-job.yaml @@ -0,0 +1,78 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: buzz-key-vault-conformance + namespace: buzz + labels: + app.kubernetes.io/name: buzz-key-vault-conformance + app.kubernetes.io/part-of: buzz +spec: + backoffLimit: 0 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/name: buzz-key-vault-conformance + azure.workload.identity/use: "true" + spec: + serviceAccountName: buzz-relay + restartPolicy: Never + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: verify + image: mcr.microsoft.com/azure-cli@sha256:ca3aac93457acc608b5b37f770f1c8a63714183b2cb42344580e2185d005b0f2 + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -ceu + - | + for name in \ + buzz-relay-private-key \ + buzz-agent-private-key \ + buzz-agent-public-key \ + buzz-relay-owner-pubkey \ + buzz-git-hook-hmac-secret \ + buzz-database-url \ + buzz-postgres-admin-password \ + buzz-database-password \ + buzz-postgres-host \ + buzz-redis-password \ + buzz-redis-url \ + buzz-bridge-internal-token \ + buzz-front-door-origin-secret + do + test -s "/mnt/secrets-store/${name}" + done + + grep -Eq '^[a-f0-9]{64}$' /mnt/secrets-store/buzz-relay-owner-pubkey + grep -Eq '^[A-Za-z0-9_-]{32,128}$' /mnt/secrets-store/buzz-front-door-origin-secret + echo "Azure Key Vault CSI conformance passed for all 13 Buzz runtime secrets." + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + volumeMounts: + - name: secrets-store + mountPath: /mnt/secrets-store + readOnly: true + volumes: + - name: secrets-store + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: buzz-runtime diff --git a/deploy/azure/namespace.yaml b/deploy/azure/namespace.yaml new file mode 100644 index 00000000000..1206cd62b96 --- /dev/null +++ b/deploy/azure/namespace.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: buzz + labels: + app.kubernetes.io/part-of: buzz + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: buzz-relay + namespace: buzz + labels: + app.kubernetes.io/part-of: buzz + annotations: + azure.workload.identity/client-id: __RELAY_IDENTITY_CLIENT_ID__ +automountServiceAccountToken: true diff --git a/deploy/azure/prod-values.yaml b/deploy/azure/prod-values.yaml new file mode 100644 index 00000000000..1a6885a2c7c --- /dev/null +++ b/deploy/azure/prod-values.yaml @@ -0,0 +1,119 @@ +replicaCount: 2 +image: + repository: __RELAY_IMAGE_REPOSITORY__ + tag: unused + digest: __RELAY_IMAGE_DIGEST__ + pullPolicy: IfNotPresent +relayUrl: wss://chat.kiingo.com +mediaBaseUrl: https://chat.kiingo.com/media +ownerPubkey: __RELAY_OWNER_PUBKEY__ +secrets: + existingSecret: buzz-runtime +relay: + requireAuthToken: true + requireRelayMembership: true + requireMediaGetAuth: true + allowNipOaAuth: true + pubkeyAllowlist: false + huddleAudioAvailable: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: DoNotSchedule + labelSelector: + matchLabels: + app.kubernetes.io/name: buzz + app.kubernetes.io/instance: buzz + app.kubernetes.io/component: relay + extraVolumeMounts: + - name: buzz-key-vault + mountPath: /mnt/secrets-store + readOnly: true +extraVolumes: + - name: buzz-key-vault + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: buzz-runtime +serviceAccount: + create: false + name: buzz-relay +podDisruptionBudget: + enabled: true + minAvailable: 1 +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/proxy-http-version: "1.1" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/enable-modsecurity: "true" + nginx.ingress.kubernetes.io/modsecurity-snippet: | + SecRuleEngine On + SecRule &REQUEST_HEADERS:X-Azure-FDID "@eq 0" "id:106,phase:1,deny,status:403,log,msg:'Front Door ID missing'" + SecRule REQUEST_HEADERS:X-Azure-FDID "!@streq __FRONT_DOOR_ID__" "id:107,phase:1,deny,status:403,log,msg:'Front Door ID invalid'" + SecRule REQUEST_HEADERS:X-Buzz-Origin-Verify "!@streq __ORIGIN_VERIFICATION_SECRET__" "id:108,phase:1,deny,status:403,log,msg:'Origin verification invalid'" + hosts: + - host: chat.kiingo.com + paths: + - path: / + pathType: Prefix + tls: + - hosts: + - buzz-origin.kiingo.com + secretName: buzz-origin-tls +persistence: + git: + enabled: false + size: 4Gi +postgresql: + enabled: false +redis: + enabled: true + architecture: standalone + auth: + enabled: true + existingSecret: buzz-runtime + existingSecretPasswordKey: redis-password + persistence: + enabled: false + networkPolicy: + enabled: true + allowExternal: false + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi +objectStorage: + backend: azure + azure: + account: __STORAGE_ACCOUNT_NAME__ + workloadIdentityClientId: __RELAY_IDENTITY_CLIENT_ID__ + mediaContainer: buzz-media + gitContainer: buzz-git +minio: + enabled: false +migrate: + autoMigrate: false +serviceMonitor: + enabled: false diff --git a/deploy/azure/secret-provider-class.yaml b/deploy/azure/secret-provider-class.yaml new file mode 100644 index 00000000000..f4dd5109a09 --- /dev/null +++ b/deploy/azure/secret-provider-class.yaml @@ -0,0 +1,84 @@ +apiVersion: secrets-store.csi.x-k8s.io/v1 +kind: SecretProviderClass +metadata: + name: buzz-runtime + namespace: buzz +spec: + provider: azure + secretObjects: + - secretName: buzz-runtime + type: Opaque + data: + - objectName: buzz-relay-private-key + key: BUZZ_RELAY_PRIVATE_KEY + - objectName: buzz-agent-private-key + key: BUZZ_PRIVATE_KEY + - objectName: buzz-agent-public-key + key: BUZZ_AGENT_PUBLIC_KEY + - objectName: buzz-relay-owner-pubkey + key: RELAY_OWNER_PUBKEY + - objectName: buzz-git-hook-hmac-secret + key: BUZZ_GIT_HOOK_HMAC_SECRET + - objectName: buzz-database-url + key: DATABASE_URL + - objectName: buzz-postgres-admin-password + key: POSTGRES_ADMIN_PASSWORD + - objectName: buzz-database-password + key: BUZZ_DATABASE_PASSWORD + - objectName: buzz-postgres-host + key: POSTGRES_HOST + - objectName: buzz-redis-password + key: redis-password + - objectName: buzz-redis-url + key: REDIS_URL + - objectName: buzz-bridge-internal-token + key: BUZZ_BRIDGE_INTERNAL_TOKEN + - objectName: buzz-front-door-origin-secret + key: BUZZ_FRONT_DOOR_ORIGIN_SECRET + parameters: + usePodIdentity: "false" + clientID: __RELAY_IDENTITY_CLIENT_ID__ + keyvaultName: __KEY_VAULT_NAME__ + cloudName: AzurePublicCloud + tenantId: __TENANT_ID__ + objects: | + array: + - | + objectName: buzz-relay-private-key + objectType: secret + - | + objectName: buzz-agent-private-key + objectType: secret + - | + objectName: buzz-agent-public-key + objectType: secret + - | + objectName: buzz-relay-owner-pubkey + objectType: secret + - | + objectName: buzz-git-hook-hmac-secret + objectType: secret + - | + objectName: buzz-database-url + objectType: secret + - | + objectName: buzz-postgres-admin-password + objectType: secret + - | + objectName: buzz-database-password + objectType: secret + - | + objectName: buzz-postgres-host + objectType: secret + - | + objectName: buzz-redis-password + objectType: secret + - | + objectName: buzz-redis-url + objectType: secret + - | + objectName: buzz-bridge-internal-token + objectType: secret + - | + objectName: buzz-front-door-origin-secret + objectType: secret diff --git a/deploy/azure/storage-conformance-job.yaml b/deploy/azure/storage-conformance-job.yaml new file mode 100644 index 00000000000..5eff335d4f5 --- /dev/null +++ b/deploy/azure/storage-conformance-job.yaml @@ -0,0 +1,50 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: buzz-azure-storage-conformance + namespace: buzz + labels: + app.kubernetes.io/part-of: buzz + app.kubernetes.io/component: storage-conformance +spec: + backoffLimit: 0 + activeDeadlineSeconds: 900 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/part-of: buzz + app.kubernetes.io/component: storage-conformance + azure.workload.identity/use: "true" + spec: + restartPolicy: Never + serviceAccountName: buzz-relay + automountServiceAccountToken: true + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: conformance + image: __CONFORMANCE_IMAGE_REPOSITORY__@__CONFORMANCE_IMAGE_DIGEST__ + imagePullPolicy: IfNotPresent + env: + - name: BUZZ_AZURE_TEST + value: "1" + - name: BUZZ_AZURE_STORAGE_ACCOUNT + value: __STORAGE_ACCOUNT_NAME__ + - name: BUZZ_AZURE_CONFORMANCE_CONTAINER + value: buzz-conformance + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] diff --git a/deploy/azure/storage-recovery-job.yaml b/deploy/azure/storage-recovery-job.yaml new file mode 100644 index 00000000000..90c01cfc85f --- /dev/null +++ b/deploy/azure/storage-recovery-job.yaml @@ -0,0 +1,169 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: buzz-azure-storage-recovery + namespace: buzz + labels: + app.kubernetes.io/part-of: buzz + app.kubernetes.io/component: storage-recovery +spec: + backoffLimit: 0 + activeDeadlineSeconds: 900 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/part-of: buzz + app.kubernetes.io/component: storage-recovery + azure.workload.identity/use: "true" + spec: + restartPolicy: Never + serviceAccountName: buzz-relay + automountServiceAccountToken: true + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: recovery + image: mcr.microsoft.com/azure-cli@__AZURE_CLI_IMAGE_DIGEST__ + imagePullPolicy: IfNotPresent + env: + - name: AZURE_CONFIG_DIR + value: /tmp/.azure + - name: AZURE_SUBSCRIPTION_ID + value: __AZURE_SUBSCRIPTION_ID__ + - name: STORAGE_ACCOUNT_NAME + value: __STORAGE_ACCOUNT_NAME__ + - name: STORAGE_CONTAINER_NAME + value: buzz-conformance + command: ["/bin/bash", "-c"] + args: + - | + set -euo pipefail + az login \ + --service-principal \ + --username "${AZURE_CLIENT_ID}" \ + --tenant "${AZURE_TENANT_ID}" \ + --federated-token "$(cat "${AZURE_FEDERATED_TOKEN_FILE}")" \ + --allow-no-subscriptions \ + --output none + az account set --subscription "${AZURE_SUBSCRIPTION_ID}" + + blob="recovery/${HOSTNAME}.txt" + printf 'version-one' >/tmp/version-one.txt + printf 'version-two' >/tmp/version-two.txt + + az storage blob upload \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --name "${blob}" \ + --file /tmp/version-one.txt \ + --content-type text/plain \ + --overwrite true \ + --auth-mode login \ + --output none + version_one="$(az storage blob list \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --prefix "${blob}" \ + --include v \ + --auth-mode login \ + --query "[?name=='${blob}'] | sort_by(@, &properties.lastModified)[-1].versionId" \ + --output tsv)" + test -n "${version_one}" + + az storage blob upload \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --name "${blob}" \ + --file /tmp/version-two.txt \ + --content-type text/plain \ + --overwrite true \ + --auth-mode login \ + --output none + az storage blob download \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --name "${blob}" \ + --version-id "${version_one}" \ + --file /tmp/restored-version.txt \ + --overwrite \ + --auth-mode login \ + --output none + test "$(cat /tmp/version-one.txt)" = "$(cat /tmp/restored-version.txt)" + + az storage blob upload \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --name "${blob}" \ + --file /tmp/restored-version.txt \ + --content-type text/plain \ + --overwrite true \ + --auth-mode login \ + --output none + az storage blob download \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --name "${blob}" \ + --file /tmp/current-after-version-restore.txt \ + --overwrite \ + --auth-mode login \ + --output none + test "$(cat /tmp/version-one.txt)" = "$(cat /tmp/current-after-version-restore.txt)" + + az storage blob delete \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --name "${blob}" \ + --auth-mode login \ + --output none + test "$(az storage blob exists \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --name "${blob}" \ + --auth-mode login \ + --query exists \ + --output tsv)" = "false" + + az storage blob download \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --name "${blob}" \ + --version-id "${version_one}" \ + --file /tmp/restored-after-delete.txt \ + --overwrite \ + --auth-mode login \ + --output none + az storage blob upload \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --name "${blob}" \ + --file /tmp/restored-after-delete.txt \ + --content-type text/plain \ + --overwrite true \ + --auth-mode login \ + --output none + az storage blob download \ + --account-name "${STORAGE_ACCOUNT_NAME}" \ + --container-name "${STORAGE_CONTAINER_NAME}" \ + --name "${blob}" \ + --file /tmp/current-after-delete-recovery.txt \ + --overwrite \ + --auth-mode login \ + --output none + test "$(cat /tmp/version-one.txt)" = "$(cat /tmp/current-after-delete-recovery.txt)" + echo "Azure Blob version restore and deletion recovery passed for ${blob}." + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index a7c4bcf63b2..290f14614c0 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -1,12 +1,12 @@ # Buzz Helm Chart -[Buzz](https://github.com/block/buzz) is a Nostr-based messaging platform for human–agent collaboration: a single relay binary serving WebSocket + REST + web UI, backed by PostgreSQL, Redis, and S3-compatible object storage. +[Buzz](https://github.com/block/buzz) is a Nostr-based messaging platform for human–agent collaboration: a single relay binary serving WebSocket + REST + web UI, backed by PostgreSQL, Redis, and S3-compatible or Azure Blob object storage. This chart has two operating profiles selected by values: | Profile | When | What you get | |---|---|---| -| **Production** (default) | Self-hosted multi-tenant, regulated, or GitOps-managed | External managed Postgres/Redis/S3, `secrets.existingSecret:`, no chart-side autogen, HA-capable (`replicaCount ≥ 2`) | +| **Production** (default) | Self-hosted multi-tenant, regulated, or GitOps-managed | External managed Postgres/Redis/object storage, `secrets.existingSecret:`, no chart-side autogen, HA-capable (`replicaCount ≥ 2`) | | **Quickstart** (eval) | Eval, single-node, one-off demo | In-cluster Postgres + Redis + MinIO subcharts/Deployments, chart auto-generates relay + service secrets, single replica | ## Quickstart (eval only) @@ -35,6 +35,12 @@ The chart is designed for ArgoCD and Flux. Both render charts with `helm templat Production deploys MUST use `secrets.existingSecret:`. The Secret is consumed for any keys present and ignored for keys missing — extras are harmless. +For Azure Blob Storage, set `objectStorage.backend=azure`, the storage account +and two container names, and `objectStorage.azure.workloadIdentityClientId`. +The chart labels the Pod and annotates its ServiceAccount for AKS workload +identity; no storage account key is stored in Kubernetes. Grant that managed +identity `Storage Blob Data Contributor` only on the two configured containers. + See: - [`examples/argocd-app.yaml`](examples/argocd-app.yaml) — ArgoCD Application @@ -165,8 +171,9 @@ Save these. Losing any of them is data loss. See NOTES.txt printed by `helm inst 1. `BUZZ_RELAY_PRIVATE_KEY` — relay identity. Rotating it = new identity (federation peers will not recognize the relay). 2. PostgreSQL database — the canonical event store. -3. S3 bucket — media blobs (chart default bucket: `buzz-media`). -4. Git PVC — repo on-disk state served by the relay's git endpoint. +3. Object storage — media blobs and the Git/CAS container. Azure deployments + should enable blob versioning, soft delete, and immutable backup retention. +4. Git scratch volumes are disposable caches and are not backup sources. 5. Owner private key — held by the operator, not by this chart. Restore by re-installing with the same `ownerPubkey`. ## Honest limitations (v1) diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl index ff070379ebc..13efe0d1fd6 100644 --- a/deploy/charts/buzz/templates/_helpers.tpl +++ b/deploy/charts/buzz/templates/_helpers.tpl @@ -53,9 +53,13 @@ app.kubernetes.io/component: relay {{- end -}} {{- define "buzz.image" -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} +{{- else -}} {{- $tag := default .Chart.AppVersion .Values.image.tag -}} {{- printf "%s:%s" .Values.image.repository $tag -}} {{- end -}} +{{- end -}} {{/* Name of the chart-managed Secret holding relay-identity material and any diff --git a/deploy/charts/buzz/templates/_validate.tpl b/deploy/charts/buzz/templates/_validate.tpl index 946424f9a39..b96a9847999 100644 --- a/deploy/charts/buzz/templates/_validate.tpl +++ b/deploy/charts/buzz/templates/_validate.tpl @@ -75,10 +75,30 @@ surface at template time regardless of which manifest helm renders first. {{- fail "Postgres source missing: enable postgresql.enabled=true, set externalPostgresql.url, or provide secrets.existingSecret with key DATABASE_URL." -}} {{- end -}} -{{/* S3 / object-storage source must exist somewhere (relay hard-fails its - startup conformance probe without a reachable bucket). */}} -{{- if not (or .Values.minio.enabled .Values.s3.endpoint .Values.secrets.existingSecret) -}} - {{- fail "S3/object-storage source missing: enable minio.enabled=true (quickstart in-cluster), set s3.endpoint + s3.bucket + credentials, or provide secrets.existingSecret with keys BUZZ_S3_ACCESS_KEY + BUZZ_S3_SECRET_KEY. The relay runs a startup S3 conformance probe and exits if storage is unreachable." -}} +{{/* Object-storage source must exist somewhere (relay hard-fails its startup + conformance probe without a reachable backend). */}} +{{- if eq .Values.objectStorage.backend "azure" -}} + {{- if .Values.minio.enabled -}} + {{- fail "objectStorage.backend=azure is incompatible with minio.enabled=true" -}} + {{- end -}} + {{- if not .Values.objectStorage.azure.account -}} + {{- fail "objectStorage.azure.account is required when objectStorage.backend=azure" -}} + {{- end -}} + {{- if not .Values.objectStorage.azure.workloadIdentityClientId -}} + {{- fail "objectStorage.azure.workloadIdentityClientId is required when objectStorage.backend=azure" -}} + {{- end -}} + {{- if not .Values.objectStorage.azure.mediaContainer -}} + {{- fail "objectStorage.azure.mediaContainer is required when objectStorage.backend=azure" -}} + {{- end -}} + {{- if not .Values.objectStorage.azure.gitContainer -}} + {{- fail "objectStorage.azure.gitContainer is required when objectStorage.backend=azure" -}} + {{- end -}} +{{- else if eq .Values.objectStorage.backend "s3" -}} + {{- if not (or .Values.minio.enabled .Values.s3.endpoint .Values.secrets.existingSecret) -}} + {{- fail "S3/object-storage source missing: enable minio.enabled=true (quickstart in-cluster), set s3.endpoint + s3.bucket + credentials, or provide secrets.existingSecret with keys BUZZ_S3_ACCESS_KEY + BUZZ_S3_SECRET_KEY." -}} + {{- end -}} +{{- else -}} + {{- fail "objectStorage.backend must be one of: s3, azure" -}} {{- end -}} {{- end -}} diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index bf2df4c2c86..89ab56a82bb 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -21,6 +21,9 @@ spec: metadata: labels: {{- include "buzz.relaySelectorLabels" . | nindent 8 }} + {{- if eq .Values.objectStorage.backend "azure" }} + azure.workload.identity/use: "true" + {{- end }} {{- with .Values.relay.podLabels }} {{- toYaml . | nindent 8 }} {{- end }} @@ -164,12 +167,21 @@ spec: - { name: BUZZ_GIT_MAX_REPOS_PER_PUBKEY, value: {{ .Values.git.maxReposPerPubkey | quote }} } - { name: BUZZ_GIT_MAX_CONCURRENT_OPS, value: {{ .Values.git.maxConcurrentOps | quote }} } + - { name: BUZZ_OBJECT_STORAGE_BACKEND, value: {{ .Values.objectStorage.backend | quote }} } + {{- if eq .Values.objectStorage.backend "azure" }} + - { name: BUZZ_AZURE_STORAGE_ACCOUNT, value: {{ .Values.objectStorage.azure.account | quote }} } + - { name: BUZZ_AZURE_MEDIA_CONTAINER, value: {{ .Values.objectStorage.azure.mediaContainer | quote }} } + - { name: BUZZ_AZURE_GIT_CONTAINER, value: {{ .Values.objectStorage.azure.gitContainer | quote }} } + {{- end }} + # ── S3 (non-secret) ────────────────────────────────────── + {{- if eq .Values.objectStorage.backend "s3" }} {{- $s3Endpoint := include "buzz.s3Endpoint" . }} {{- if $s3Endpoint }} - { name: BUZZ_S3_ENDPOINT, value: {{ $s3Endpoint | quote }} } {{- end }} - { name: BUZZ_S3_BUCKET, value: {{ .Values.s3.bucket | quote }} } + {{- end }} # ── Secrets (from chart-managed or existing) ───────────── - name: BUZZ_RELAY_PRIVATE_KEY diff --git a/deploy/charts/buzz/templates/serviceaccount.yaml b/deploy/charts/buzz/templates/serviceaccount.yaml index 60be80038f7..c3fff887645 100644 --- a/deploy/charts/buzz/templates/serviceaccount.yaml +++ b/deploy/charts/buzz/templates/serviceaccount.yaml @@ -6,8 +6,13 @@ metadata: name: {{ include "buzz.serviceAccountName" . }} labels: {{- include "buzz.labels" . | nindent 4 }} - {{- with .Values.serviceAccount.annotations }} + {{- if or (eq .Values.objectStorage.backend "azure") .Values.serviceAccount.annotations }} annotations: + {{- if eq .Values.objectStorage.backend "azure" }} + azure.workload.identity/client-id: {{ .Values.objectStorage.azure.workloadIdentityClientId | quote }} + {{- end }} + {{- with .Values.serviceAccount.annotations }} {{- toYaml . | nindent 4 }} + {{- end }} {{- end }} {{- end }} diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 3e044f5d7c1..05d71045a9c 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -8,6 +8,24 @@ templates: - templates/service.yaml - templates/pvc-git.yaml tests: + - it: renders an immutable digest reference when configured + set: + image.repository: acr.example.com/buzz-relay + image.tag: ignored + image.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: acr.example.com/buzz-relay@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + template: templates/deployment.yaml + - it: renders cleanly in production profile (external pg/redis) set: relayUrl: wss://buzz.example.com @@ -47,6 +65,51 @@ tests: value: "true" template: templates/deployment.yaml + - it: renders Azure Blob storage with workload identity and separate containers + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + objectStorage.backend: azure + objectStorage.azure.account: buzzstorage + objectStorage.azure.workloadIdentityClientId: 11111111-1111-1111-1111-111111111111 + objectStorage.azure.mediaContainer: buzz-media + objectStorage.azure.gitContainer: buzz-git + asserts: + - equal: + path: spec.template.metadata.labels["azure.workload.identity/use"] + value: "true" + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_OBJECT_STORAGE_BACKEND + value: azure + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_AZURE_STORAGE_ACCOUNT + value: buzzstorage + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_AZURE_MEDIA_CONTAINER + value: buzz-media + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_AZURE_GIT_CONTAINER + value: buzz-git + template: templates/deployment.yaml + - equal: + path: metadata.annotations["azure.workload.identity/client-id"] + value: 11111111-1111-1111-1111-111111111111 + template: templates/serviceaccount.yaml + - it: lets an explicit value opt out of media read auth for dev/public deployments set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml index f0a38697952..8a5e69c4ac4 100644 --- a/deploy/charts/buzz/tests/validation_test.yaml +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -66,3 +66,15 @@ tests: asserts: - failedTemplate: errorPattern: "S3/object-storage source missing" + + - it: fails when Azure workload identity client ID is missing + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + objectStorage.backend: azure + objectStorage.azure.account: buzzstorage + objectStorage.azure.workloadIdentityClientId: "" + asserts: + - failedTemplate: + errorPattern: "objectStorage.azure.workloadIdentityClientId is required" diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 53bb29bb608..0f334cee372 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -15,6 +15,10 @@ "properties": { "repository": { "type": "string", "minLength": 1 }, "tag": { "type": "string" }, + "digest": { + "type": "string", + "pattern": "^(|sha256:[a-f0-9]{64})$" + }, "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }, "pullSecrets": { "type": "array", @@ -192,6 +196,25 @@ "url": { "type": "string", "pattern": "^(rediss?://.+)?$" } } }, + "objectStorage": { + "type": "object", + "additionalProperties": false, + "properties": { + "backend": { "type": "string", "enum": ["s3", "azure"] }, + "azure": { + "type": "object", + "additionalProperties": false, + "properties": { + "account": { "type": "string" }, + "workloadIdentityClientId": { "type": "string" }, + "mediaContainer": { "type": "string", "minLength": 1 }, + "gitContainer": { "type": "string", "minLength": 1 } + }, + "required": ["account", "workloadIdentityClientId", "mediaContainer", "gitContainer"] + } + }, + "required": ["backend", "azure"] + }, "s3": { "type": "object", "additionalProperties": false, diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 8ac5086e275..817fc44e81e 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -25,6 +25,7 @@ quickstart: false image: repository: ghcr.io/block/buzz tag: "" # empty → .Chart.AppVersion + digest: "" # sha256:... overrides tag for immutable production deploys pullPolicy: IfNotPresent pullSecrets: [] @@ -335,6 +336,16 @@ externalRedis: # (default 300 s BUZZ_USAGE_METRICS_INTERVAL_SECS), not at sweep-interval # cadence — so a permanently missing s3:ListBucket yields one cheap LIST call # per tick until the permission is added. +objectStorage: + # `s3` preserves the existing S3/MinIO behavior. `azure` uses AKS + # workload identity and separate containers for media and Git/CAS data. + backend: s3 + azure: + account: "" + workloadIdentityClientId: "" + mediaContainer: "buzz-media" + gitContainer: "buzz-git" + s3: endpoint: "" bucket: "buzz-media" diff --git a/desktop/scripts/build-release-config.mjs b/desktop/scripts/build-release-config.mjs index 389d18aec5d..43b2fc4a054 100644 --- a/desktop/scripts/build-release-config.mjs +++ b/desktop/scripts/build-release-config.mjs @@ -26,6 +26,7 @@ const outputConfigPath = resolve( const updaterPubkey = process.env.BUZZ_UPDATER_PUBLIC_KEY; const updaterEndpoint = process.env.BUZZ_UPDATER_ENDPOINT; +const windowsSignCommand = process.env.BUZZ_WINDOWS_SIGN_COMMAND?.trim(); const missing = []; if (!updaterPubkey) missing.push("BUZZ_UPDATER_PUBLIC_KEY"); @@ -43,6 +44,13 @@ const releaseConfig = { minimumSystemVersion: "10.15", }, createUpdaterArtifacts: true, + ...(windowsSignCommand + ? { + windows: { + signCommand: windowsSignCommand, + }, + } + : {}), }, plugins: { updater: { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index d4f7a4a2d4c..84bfc934781 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -987,6 +987,18 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "buzz-azure-storage" +version = "0.1.0" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "object_store", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "buzz-core" version = "0.1.0" @@ -1097,6 +1109,7 @@ version = "0.1.0" dependencies = [ "axum", "blurhash", + "buzz-azure-storage", "buzz-core", "bytes", "chrono", @@ -1508,7 +1521,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1745,7 +1758,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -2149,7 +2162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -3099,8 +3112,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]] @@ -3845,7 +3858,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.5.3", + "windows-registry 0.6.1", ] [[package]] @@ -3860,7 +3873,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4370,6 +4383,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -6131,7 +6153,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", @@ -6464,6 +6486,44 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64 0.22.1", + "bytes", + "chrono", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body-util", + "httparse", + "humantime", + "hyper", + "itertools 0.15.0", + "parking_lot", + "percent-encoding", + "quick-xml 0.41.0", + "rand 0.10.2", + "reqwest 0.13.4", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", + "web-time", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6699,7 +6759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -7409,8 +7469,8 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.4.1", - "itertools", + "heck 0.5.0", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -7429,7 +7489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.118", @@ -7537,6 +7597,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", + "serde", ] [[package]] @@ -7747,7 +7808,7 @@ dependencies = [ "compact_str 0.9.1", "critical-section", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "kasuari", "lru 0.18.1", "palette", @@ -7812,7 +7873,7 @@ dependencies = [ "hashbrown 0.17.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -10150,7 +10211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -11068,7 +11129,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -11786,7 +11847,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -12415,8 +12476,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]] @@ -12599,7 +12660,7 @@ dependencies = [ "futures-util", "getrandom 0.4.3", "heapify", - "itertools", + "itertools 0.14.0", "lazy_static", "lz4_flex", "more-asserts", @@ -12629,7 +12690,7 @@ dependencies = [ "chrono", "gearhash", "http", - "itertools", + "itertools 0.14.0", "lazy_static", "more-asserts", "rand 0.10.2", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8bb643fea36..dc38b133868 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-build = { version = "2", features = [] } +url = "2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 0fb3747718a..56922885e62 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -16,6 +16,7 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_CODEX_ENROLLMENT_URL"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { @@ -97,6 +98,15 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1"); } + if let Ok(raw_url) = std::env::var("BUZZ_BUILD_CODEX_ENROLLMENT_URL") { + let url = url::Url::parse(raw_url.trim()) + .unwrap_or_else(|error| panic!("BUZZ_BUILD_CODEX_ENROLLMENT_URL is invalid: {error}")); + if url.scheme() != "https" || url.host_str().is_none() { + panic!("BUZZ_BUILD_CODEX_ENROLLMENT_URL must be an absolute HTTPS URL"); + } + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_CODEX_ENROLLMENT_URL={url}"); + } + let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY") .ok() .map(|value| value.trim().to_string()) diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 2840c0ade68..652eadc21d9 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -59,6 +59,14 @@ pub fn auto_connect_default_relay_enabled() -> bool { option_env!("BUZZ_DESKTOP_BUILD_AUTO_CONNECT_DEFAULT_RELAY").is_some() } +#[tauri::command] +pub fn get_codex_enrollment_url() -> Option { + option_env!("BUZZ_DESKTOP_BUILD_CODEX_ENROLLMENT_URL") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + #[cfg(test)] mod auto_connect_default_relay_tests { use super::auto_connect_default_relay_enabled; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35f4eae866a..af6be2b9a79 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -688,6 +688,7 @@ pub fn run() { get_os_idle_seconds, get_default_relay_url, auto_connect_default_relay_enabled, + get_codex_enrollment_url, get_legacy_workspace_storage, is_shared_identity, get_relay_ws_url, diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index e13ab2baad6..c8f008836d7 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -50,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. -const NEST_SKILL_VERSION: u32 = 4; +const NEST_SKILL_VERSION: u32 = 5; const BEGIN_MARKER: &str = ""; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index d2c415e725c..031b049a495 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -29,6 +29,18 @@ fn init_nest_dir_prod_sets_buzz() { } } +#[test] +fn nest_skill_contains_safe_mention_workflow() { + assert!(BUZZ_CLI_SKILL_MD.contains("--mention ")); + assert!(BUZZ_CLI_SKILL_MD.contains("every presentation-only name that should notify")); + assert!(BUZZ_CLI_SKILL_MD + .contains("permits unresolved or ambiguous `@Name` text as presentation-only")); + assert!(BUZZ_CLI_SKILL_MD.contains("signed event's `mention_pubkeys`")); + assert!(BUZZ_CLI_SKILL_MD.contains("no follow-up verification command is needed")); + assert!(BUZZ_CLI_SKILL_MD.contains("Add membership separately only when authorized")); + assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); +} + #[test] fn ensure_nest_creates_all_dirs_and_agents_md() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index fefdfa77fa1..79a5ea301d4 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -87,14 +87,11 @@ Write commands are unaffected. `--format json` (default) returns full fields. ## Communication Patterns -**Mentions that notify:** Use `@Name` directly in message content — the CLI auto-resolves channel members by name and adds the required p-tags. No `--mention` flag exists or is needed. `nostr:npub1…` inline references are also auto-resolved to p-tags without needing a flag. +**Mentions that notify:** Keep readable `@Name` text in message content and, when intended pubkeys are known, pass the identities in the same send with repeatable `--mention `. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add recipients. Include a pubkey for every presentation-only name that should notify. The CLI reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without explicit identities, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, then retry; sending never changes membership automatically. ```bash -# ✅ Correct — notification delivered automatically -buzz messages send --channel --content "@Alice check this" - -# Multiple mentions — same pattern -buzz messages send --channel --content "@Alice @Bob review please" +buzz messages send --channel \ + --content "@Alice check this" --mention ``` ## DM Management diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 75f57257ccc..abbbf296108 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -231,6 +231,7 @@ export function AppShell() { const relayConnectionCard = useSidebarRelayConnectionCard( channelsErrorMessage, communitiesHook.activeCommunity?.relayUrl, + `${communitiesHook.activeCommunity?.id ?? "none"}-${communitiesHook.reinitKey}`, ); const memberChannels = React.useMemo( () => channels.filter((channel) => channel.isMember), diff --git a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts index 53ef44b28d7..57e458bc911 100644 --- a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts +++ b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts @@ -4,7 +4,11 @@ import { init, SearchIndex } from "emoji-mart"; import data from "@emoji-mart/data"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import { fuzzyStandardEmoji, rankByShortcode } from "@/shared/lib/emojiSearch"; +import { + fuzzyStandardEmoji, + rankByShortcode, + rankShortcodeMatchesFirst, +} from "@/shared/lib/emojiSearch"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import type { AutocompleteEdit } from "./useRichTextEditor"; @@ -18,7 +22,7 @@ export type EmojiSuggestion = { const EMOJI_DEBOUNCE_MS = 120; const MIN_QUERY_LENGTH = 2; -const MAX_RESULTS = 8; +const UNLIMITED_RESULTS = Number.POSITIVE_INFINITY; init({ data }); @@ -81,7 +85,7 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { emojiQuery, customEmojiRef.current, (e) => e.shortcode, - MAX_RESULTS, + UNLIMITED_RESULTS, ).map((e) => ({ id: e.shortcode, name: e.shortcode, @@ -89,7 +93,10 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { url: rewriteRelayUrl(e.url), })); - SearchIndex.search(emojiQuery) + SearchIndex.search(emojiQuery, { + caller: "useEmojiAutocomplete", + maxResults: UNLIMITED_RESULTS, + }) .then( ( results: Array<{ @@ -106,23 +113,28 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { native: emoji.skins[0]?.native ?? "", })) .filter((e) => e.native !== ""); - // Top up remaining slots with fuzzy shortcode matches emoji-mart - // missed — its token-prefix search can't cross `_` (so `pointup` - // finds nothing). Skip ids already shown to avoid duplicates. + // Add fuzzy shortcode matches emoji-mart missed — its token-prefix + // search can't cross `_` (so `pointup` finds nothing). Skip ids + // already shown to avoid duplicates. const shown = new Set( [...customMatches, ...standard].map((e) => e.id), ); const fuzzy: EmojiSuggestion[] = fuzzyStandardEmoji( emojiQuery, - MAX_RESULTS - customMatches.length - standard.length, + UNLIMITED_RESULTS, shown, ).map((e) => ({ id: e.id, name: e.name, native: e.native })); - // Custom emoji first (community-specific), then standard, then fuzzy. - const merged = [...customMatches, ...standard, ...fuzzy].slice( - 0, - MAX_RESULTS, + // Rank exact/prefix shortcode matches across custom and standard emoji + // before semantic and weaker matches (for example, `joy` before + // `bufo_joy`). Keep emoji-mart's name/keyword results ahead of loose + // substring and subsequence matches. + setSuggestions( + rankShortcodeMatchesFirst( + emojiQuery, + [...standard, ...customMatches, ...fuzzy], + (emoji) => emoji.id, + ), ); - setSuggestions(merged); setEmojiSelectedIndex(0); }, ) diff --git a/desktop/src/features/messages/ui/EmojiAutocomplete.tsx b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx index 3b6fa3a2d24..d44933aef1f 100644 --- a/desktop/src/features/messages/ui/EmojiAutocomplete.tsx +++ b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx @@ -2,6 +2,10 @@ import * as React from "react"; import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { cn } from "@/shared/lib/cn"; +import { + type ListVirtualizer, + VirtualizedList, +} from "@/shared/ui/VirtualizedList"; import { POPOVER_CUSTOM_ENTER_MOTION_CLASS, POPOVER_SHADOW_STYLE, @@ -21,15 +25,21 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({ onSelect, position = "above", }: EmojiAutocompleteProps) { - const listRef = React.useRef(null); + const listVirtualizerRef = React.useRef(null); React.useEffect(() => { - const activeItem = listRef.current?.children[selectedIndex] as - | HTMLElement - | undefined; - activeItem?.scrollIntoView({ block: "nearest" }); + listVirtualizerRef.current?.scrollToIndex(selectedIndex, { + align: "auto", + }); }, [selectedIndex]); + const handleVirtualizer = React.useCallback( + (virtualizer: ListVirtualizer) => { + listVirtualizerRef.current = virtualizer; + }, + [], + ); + if (suggestions.length === 0) { return null; } @@ -43,47 +53,55 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({ >
- {suggestions.map((suggestion, index) => ( - - ))} + suggestion.id} + items={suggestions} + onVirtualizer={handleVirtualizer} + renderItem={(suggestion, index) => ( + + )} + />
); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index 1fdfb058574..ee3fec1a988 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -247,13 +247,20 @@ function Harness({ channelId, onTargetSettled, refs }) { return null; } -function BottomStateHarness({ messages, onState, refs }) { +function BottomStateHarness({ + messages, + onState, + refs, + targetMessageId = null, +}) { const anchored = useAnchoredScroll({ channelId: "conversation", contentRef: refs.content, isLoading: false, messages, + pinTargetCentered: targetMessageId !== null, scrollContainerRef: refs.container, + targetMessageId, }); onState(anchored); return null; @@ -329,6 +336,90 @@ test("channel change attaches pinned-center observers after refs mount", async ( }); }); +test("arrival at the physical floor does not preserve a stale unread state", async () => { + const refs = { + container: { current: null }, + content: { current: null }, + }; + const root = createRoot(document.createElement("div")); + const nodes = makePinnedCenterNodes(); + refs.container.current = nodes.container; + refs.content.current = nodes.content; + let state = null; + const render = (messages) => + root.render( + React.createElement(BottomStateHarness, { + messages, + onState: (nextState) => { + state = nextState; + }, + refs, + }), + ); + + await act(async () => render([{ id: "first" }])); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + assert.equal(state.isAtBottom, false); + + // Native anchoring can return the viewport to the floor without a scroll or + // resize callback, leaving only the hook's cached message anchor stale. + nodes.container.scrollTop = + nodes.container.scrollHeight - nodes.container.clientHeight; + await act(async () => render([{ id: "first" }, { id: "second" }])); + + assert.equal(state.isAtBottom, true); + assert.equal(state.newMessageCount, 0); + await act(async () => root.unmount()); +}); + +test("arrival does not steal an active layout target during floor-like reflow", async () => { + const refs = { + container: { current: null }, + content: { current: null }, + }; + const root = createRoot(document.createElement("div")); + const nodes = makePinnedCenterNodes(); + refs.container.current = nodes.container; + refs.content.current = nodes.content; + let state = null; + const render = (messages, targetMessageId = null) => + root.render( + React.createElement(BottomStateHarness, { + messages, + onState: (nextState) => { + state = nextState; + }, + refs, + targetMessageId, + }), + ); + + await act(async () => render([{ id: "selected" }])); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + assert.equal(state.isAtBottom, false); + + // A focus/split presentation switch can commit fresh replies while the old + // container geometry momentarily reads as the physical floor. The explicit + // layout target must win so the reading row is restored after reflow. + nodes.container.scrollTop = + nodes.container.scrollHeight - nodes.container.clientHeight; + await act(async () => + render([{ id: "selected" }, { id: "second" }], "selected"), + ); + + assert.equal(state.isAtBottom, false); + assert.equal(state.newMessageCount, 1); + await act(async () => root.unmount()); +}); + test("container resize clears a stale new-message state at the physical floor", async () => { const refs = { container: { current: null }, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index add9439599a..0bfcb3b3e2f 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -714,6 +714,22 @@ export function useAnchoredScroll({ container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); } if (newLatestArrived) setNewMessageCount(0); + } else if ( + messagesArrived > 0 && + !targetMessageId && + !virtualizerOwnsPrependAnchoring && + isAtBottomNow(container) + ) { + // A native scroll/layout callback may not have reconciled a stale + // message anchor before this append commits. If the rendered result is + // still physically at the floor (common in short threads), do not turn + // that stale anchor into a visible unread affordance. Active navigation + // targets own the viewport and must be preserved across presentation + // reflow even when the old geometry momentarily reads as the floor. + anchorRef.current = { kind: "at-bottom" }; + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + setIsAtBottom(true); + setNewMessageCount(0); } else if (messagesArrived > 0 && !virtualizerOwnsPrependAnchoring) { // Anchored mid-history. An older-history prepend grows the content above // the reading row; the browser's native scroll anchoring does NOT correct diff --git a/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx b/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx index 10ca2a377f1..4e4cef3eff1 100644 --- a/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx @@ -7,6 +7,7 @@ import { useGitBashPrerequisiteQuery, } from "@/features/agents/hooks"; import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { getCodexEnrollmentUrl } from "@/shared/api/codexEnrollment"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { SectionHeader } from "@/shared/ui/PageHeader"; @@ -15,6 +16,36 @@ import { HarnessCatalogDialog } from "./HarnessCatalogDialog"; import { HarnessRow } from "./HarnessRow"; import { stableRowOrder, yourHarnessEntries } from "./harnessCatalogLogic"; +function CodexSubscriptionCard({ enrollmentUrl }: { enrollmentUrl: string }) { + return ( +
+
+
+

ChatGPT-powered Codex

+

+ Connect your own eligible ChatGPT account through Kiingo. Buzz never + uses another employee's subscription or an administrator API + key. +

+
+ +
+
+ ); +} + function GitBashCard({ prerequisite, }: { @@ -90,6 +121,23 @@ export function HarnessesSettingsPanel() { // Incremented each time the user clicks "Check again" so HarnessRow // useEffect clears stale install results from before the refresh. const [resetEpoch, setResetEpoch] = React.useState(0); + const [codexEnrollmentUrl, setCodexEnrollmentUrl] = React.useState< + string | null + >(null); + + React.useEffect(() => { + let active = true; + void getCodexEnrollmentUrl() + .then((url) => { + if (active) setCodexEnrollmentUrl(url); + }) + .catch(() => { + if (active) setCodexEnrollmentUrl(null); + }); + return () => { + active = false; + }; + }, []); const entries = React.useMemo( () => yourHarnessEntries(runtimesQuery.data ?? []), @@ -136,6 +184,9 @@ export function HarnessesSettingsPanel() { />
+ {codexEnrollmentUrl ? ( + + ) : null} {gitBashQuery.data ? (
diff --git a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts index f92e1a02e8e..11f2bd10642 100644 --- a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts +++ b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts @@ -63,6 +63,7 @@ function isDocumentVisible() { export function useSidebarRelayConnectionCard( errorMessage?: string, relayUrl?: string | null, + relayLifecycleKey = relaySuccessKey(relayUrl), ) { const relayConnectionState = useRelayConnection(); const hasRelayUnreachableError = errorMessage @@ -79,7 +80,6 @@ export function useSidebarRelayConnectionCard( relayConnectionState === "stalled" || (relayConnectionState === "disconnected" && !hasNonUnreachableError); const isRelayConnectionConnected = relayConnectionState === "connected"; - const isRelayConnectionDisconnected = relayConnectionState === "disconnected"; const [isDismissed, setIsDismissed] = React.useState(false); const hasSuccess = React.useSyncExternalStore( subscribeRelayConnectivitySuccess, @@ -95,6 +95,8 @@ export function useSidebarRelayConnectionCard( const isRelayConnectionSuccess = hasSuccess && isRelayConnectionConnected; const canShow = isRelayConnectionActuallyDegraded || isRelayConnectionSuccess; const show = canShow && !isDismissed; + const outageActiveRef = React.useRef(false); + const outageRelayLifecycleKeyRef = React.useRef(relayLifecycleKey); const wasProblemCardVisibleRef = React.useRef(false); const { isPending: isReconnectPending, @@ -111,30 +113,41 @@ export function useSidebarRelayConnectionCard( isReconnectPending || connectivityAction === "relay-connection"; React.useEffect(() => { - if (!isRelayConnectionActuallyDegraded && !isRelayConnectionSuccess) { + if (outageRelayLifecycleKeyRef.current !== relayLifecycleKey) { + outageRelayLifecycleKeyRef.current = relayLifecycleKey; + outageActiveRef.current = false; + wasProblemCardVisibleRef.current = false; setIsDismissed(false); } - }, [isRelayConnectionSuccess, isRelayConnectionActuallyDegraded]); - React.useEffect(() => { - if (isRelayConnectionStateDegraded || isRelayConnectionDisconnected) { - setRelayConnectivitySuccess(relayUrl, false); + if (relayConnectionState === "idle") { + outageActiveRef.current = false; + wasProblemCardVisibleRef.current = false; setIsDismissed(false); + return; } - }, [isRelayConnectionDisconnected, isRelayConnectionStateDegraded, relayUrl]); - React.useEffect(() => { if (isRelayConnectionActuallyDegraded) { + if (!outageActiveRef.current) { + outageActiveRef.current = true; + setRelayConnectivitySuccess(relayUrl, false); + setIsDismissed(false); + } wasProblemCardVisibleRef.current = show && !isRelayConnectionSuccess; return; } - if (wasProblemCardVisibleRef.current && isRelayConnectionConnected) { - wasProblemCardVisibleRef.current = false; - setRelayConnectivitySuccess(relayUrl, true); + if (outageActiveRef.current && isRelayConnectionConnected) { + outageActiveRef.current = false; + if (wasProblemCardVisibleRef.current) { + wasProblemCardVisibleRef.current = false; + setRelayConnectivitySuccess(relayUrl, true); + } } }, [ isRelayConnectionSuccess, + relayLifecycleKey, + relayConnectionState, relayUrl, show, isRelayConnectionActuallyDegraded, diff --git a/desktop/src/shared/api/codexEnrollment.ts b/desktop/src/shared/api/codexEnrollment.ts new file mode 100644 index 00000000000..8df76cd8c25 --- /dev/null +++ b/desktop/src/shared/api/codexEnrollment.ts @@ -0,0 +1,5 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export function getCodexEnrollmentUrl(): Promise { + return invokeTauri("get_codex_enrollment_url"); +} diff --git a/desktop/src/shared/lib/emojiSearch.test.mjs b/desktop/src/shared/lib/emojiSearch.test.mjs index 93cb1e286a6..01a52293f50 100644 --- a/desktop/src/shared/lib/emojiSearch.test.mjs +++ b/desktop/src/shared/lib/emojiSearch.test.mjs @@ -5,6 +5,7 @@ import { fuzzyStandardEmoji, normalizeShortcode, rankByShortcode, + rankShortcodeMatchesFirst, scoreShortcodeMatch, } from "./emojiSearch.ts"; @@ -77,6 +78,35 @@ test("rankByShortcode respects the limit", () => { assert.equal(ranked.length, 2); }); +test("exact shortcode matches rank ahead of weaker custom shortcode matches", () => { + const items = [ + { code: "bufo_joy", source: "custom" }, + { code: "joy", source: "standard" }, + { code: "joy_cat", source: "custom" }, + { code: "face_with_tears_of_joy", source: "standard" }, + ]; + const ranked = rankShortcodeMatchesFirst("joy", items, (item) => item.code); + + assert.deepEqual( + ranked.map((item) => item.code), + ["joy", "joy_cat", "bufo_joy", "face_with_tears_of_joy"], + ); +}); + +test("semantic results stay ahead of loose shortcode matches", () => { + const items = [ + { code: "frowning_face", source: "semantic" }, + { code: "sandwich", source: "custom" }, + { code: "sad", source: "standard" }, + ]; + const ranked = rankShortcodeMatchesFirst("sad", items, (item) => item.code); + + assert.deepEqual( + ranked.map((item) => item.code), + ["sad", "frowning_face", "sandwich"], + ); +}); + test("fuzzyStandardEmoji surfaces point_up for `pointup`", () => { const hits = fuzzyStandardEmoji("pointup", 8, new Set()); const ids = hits.map((e) => e.id); diff --git a/desktop/src/shared/lib/emojiSearch.ts b/desktop/src/shared/lib/emojiSearch.ts index 2a0d74b9bbd..ec4ea529b59 100644 --- a/desktop/src/shared/lib/emojiSearch.ts +++ b/desktop/src/shared/lib/emojiSearch.ts @@ -114,6 +114,33 @@ export function rankByShortcode( return scored.slice(0, limit).map((s) => s.item); } +/** + * Place exact and prefix shortcode matches ahead of items that only matched an + * emoji name or keyword. This lets an exact standard emoji like `joy` beat a + * weaker custom shortcode match such as `bufo_joy`, while retaining emoji-mart's + * order for name- and keyword-only results ahead of loose shortcode matches. + */ +export function rankShortcodeMatchesFirst( + query: string, + items: readonly T[], + shortcodeOf: (item: T) => string, +): T[] { + const strongShortcodeMatches = rankByShortcode( + query, + items, + shortcodeOf, + Number.POSITIVE_INFINITY, + ).filter((item) => { + const match = scoreShortcodeMatch(query, shortcodeOf(item)); + return match !== null && match.tier <= TIER_PREFIX; + }); + const matchedItems = new Set(strongShortcodeMatches); + return [ + ...strongShortcodeMatches, + ...items.filter((item) => !matchedItems.has(item)), + ]; +} + export interface StandardEmoji { id: string; name: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7b13273c602..ecf70f9109a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -913,8 +913,8 @@ function createMockRelayMembershipEvent(): RelayEvent { * sets from distinct pubkeys so the e2e exercises the union/collapse path, not * a single relay-owned set. `:buzz:` is the stable shortcode exercised by * custom-emoji.spec.ts (claimed by BOTH members with different URLs, so the - * palette must collapse it to one deterministic winner); `:narf:` proves a - * second member's distinct emoji unions in. + * palette must collapse it to one deterministic winner); `:narf:` and + * `:bufo_joy:` prove a second member's distinct emoji unions in. */ function createMockCustomEmojiSetEvents(): RelayEvent[] { return [ @@ -941,6 +941,7 @@ function createMockCustomEmojiSetEvents(): RelayEvent[] { // member B claims :buzz: with a DIFFERENT url — unionCustomEmoji must // collapse it to one deterministic winner, never expose two URLs. ["emoji", "buzz", "https://example.com/e2e/buzz-b.png"], + ["emoji", "bufo_joy", "https://example.com/e2e/bufo-joy.png"], ], "b".repeat(64), ), @@ -10392,6 +10393,8 @@ export function maybeInstallE2eTauriMocks() { return getRelayWsUrl(activeConfig); case "auto_connect_default_relay_enabled": return activeConfig?.autoConnectDefaultRelay ?? false; + case "get_codex_enrollment_url": + return null; case "get_legacy_workspace_storage": return { workspaces: null, diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index aa345570f9d..ae20aaef25c 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -1,6 +1,9 @@ import { expect, test } from "@playwright/test"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; // Custom-emoji end-to-end guard. // @@ -77,6 +80,55 @@ test("typing a known :shortcode: renders an inline emoji node in the composer", await expect(input).not.toContainText(`:${SHORTCODE}:`); }); +test("emoji autocomplete ranks an exact standard shortcode before a custom substring", async ({ + page, +}, testInfo) => { + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially(":joy"); + + const autocomplete = page.getByTestId("emoji-autocomplete"); + await expect(autocomplete).toBeVisible(); + const labels = await autocomplete.locator("button").allTextContents(); + expect(labels[0]).toContain(":joy:"); + expect( + labels.findIndex((label) => label.includes(":bufo_joy:")), + ).toBeGreaterThan(0); + + const screenshotDir = path.resolve( + "test-results/emoji-autocomplete-screenshots", + ); + fs.mkdirSync(screenshotDir, { recursive: true }); + const screenshotPath = path.join( + screenshotDir, + "joy-exact-before-custom-substring.png", + ); + await waitForAnimations(page); + await autocomplete.screenshot({ path: screenshotPath }); + await testInfo.attach("joy-exact-before-custom-substring", { + path: screenshotPath, + contentType: "image/png", + }); +}); + +test("emoji autocomplete keeps semantic matches ahead of loose shortcode fallbacks", async ({ + page, +}) => { + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially(":sad"); + + const autocomplete = page.getByTestId("emoji-autocomplete"); + await expect(autocomplete).toBeVisible(); + await expect(autocomplete.locator("button").first()).not.toContainText( + ":sandwich:", + ); +}); + test("custom emoji deletes as a single unit (like a built-in emoji)", async ({ page, }) => { diff --git a/desktop/tests/e2e/sidebar-relay-card.spec.ts b/desktop/tests/e2e/sidebar-relay-card.spec.ts index 9505c0f10fd..b9903614d2c 100644 --- a/desktop/tests/e2e/sidebar-relay-card.spec.ts +++ b/desktop/tests/e2e/sidebar-relay-card.spec.ts @@ -84,6 +84,25 @@ async function setRelayConnectionState( }, state); } +async function emitRelayConnectionState( + page: Page, + state: RelayConnectionState, +) { + await page.evaluate((nextState) => { + const setConnectionState = ( + window as Window & { + __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: ( + state: RelayConnectionState, + ) => void; + } + ).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__; + if (!setConnectionState) { + throw new Error("Mock relay connection state helper is not installed."); + } + setConnectionState(nextState); + }, state); +} + async function expectGenericReconnectCard(page: Page) { const card = page.getByTestId("sidebar-relay-unreachable"); await expect(card).toBeVisible(); @@ -109,6 +128,55 @@ test("sidebar generic relay failures use the reconnect card", async ({ await expectGenericReconnectCard(page); }); +test("relay outage notification stays dismissed through retries and re-arms after recovery", async ({ + page, +}) => { + await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); + await page.goto("/"); + await setRelayConnectionState(page, "disconnected"); + + const card = await expectGenericReconnectCard(page); + await card + .getByRole("button", { name: "Dismiss relay notification" }) + .click({ force: true }); + await expect(card).toBeHidden(); + + // Retry churn is still the same outage: no successful connection occurred. + await emitRelayConnectionState(page, "connecting"); + await emitRelayConnectionState(page, "disconnected"); + await emitRelayConnectionState(page, "reconnecting"); + await page.waitForTimeout(2_100); + await expect(card).toBeHidden(); + + // A successful connection ends the episode and re-arms the next outage. + await setChannelsReadError(page, null); + await emitRelayConnectionState(page, "connected"); + await setChannelsReadError(page, CONNECT_ERROR); + await emitRelayConnectionState(page, "disconnected"); + await expectGenericReconnectCard(page); +}); + +test("relay outage notification re-arms after same-URL lifecycle teardown", async ({ + page, +}) => { + await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); + await page.goto("/"); + await setRelayConnectionState(page, "disconnected"); + + const card = await expectGenericReconnectCard(page); + await card + .getByRole("button", { name: "Dismiss relay notification" }) + .click({ force: true }); + await expect(card).toBeHidden(); + + // Community switches and reconnectCommunity() tear down the singleton to + // idle before applying the next lifecycle. The next lifecycle may reuse the + // same relay URL, so URL identity alone must not preserve the old dismissal. + await emitRelayConnectionState(page, "idle"); + await emitRelayConnectionState(page, "disconnected"); + await expectGenericReconnectCard(page); +}); + test("sidebar proxy sign-in failures use the reconnect card", async ({ page, }) => { diff --git a/docs/kiingo-fork-maintenance.md b/docs/kiingo-fork-maintenance.md new file mode 100644 index 00000000000..1768dfaaa08 --- /dev/null +++ b/docs/kiingo-fork-maintenance.md @@ -0,0 +1,54 @@ +# Kiingo Buzz Fork Maintenance + +Kiingo maintains `Kiingo/buzz` as a narrow Apache-2.0-compatible fork of +`block/buzz`. The fork keeps the upstream history intact so security fixes and +product changes can be merged without replaying Kiingo commits. + +## Remote contract + +```text +origin git@github.com:Kiingo/buzz.git +upstream https://github.com/block/buzz.git +``` + +Verify the URLs before synchronizing. Add the `upstream` remote when it is +absent, fetch both remotes, and review the upstream diff before changing the +fork branch. + +## Synchronization procedure + +1. Start from a clean Kiingo worktree and a dedicated branch. +2. Fetch `origin` and `upstream` without pruning or force-updating local work. +3. Merge `upstream/main` into the Kiingo branch. Do not rebase published Kiingo + commits or force-push the shared branch. +4. Resolve conflicts by preserving upstream behavior unless a documented + Kiingo production requirement intentionally differs. +5. Keep every commit DCO-compliant with `git commit -s` and retain upstream + authorship and commit history. +6. Run the targeted checks required by the changed crates and deployment + surfaces. For Rust changes this includes formatting plus the smallest + relevant crate tests. +7. Open or update the Kiingo pull request with the upstream commit merged, + conflict decisions, exact checks, and any operational migration notes. +8. Merge through the normal protected-branch workflow. Production images must + be built from the reviewed merge commit and pinned by digest. + +## License and patch boundaries + +- Preserve the root `LICENSE`, copyright statements, dependency notices, + Apache-2.0 headers, and any `NOTICE` file upstream adds in the future. +- Keep Kiingo-specific Azure, identity, and bridge seams configurable. Avoid + replacing portable upstream behavior when a backend interface or chart value + can keep both paths supported. +- Prefer small upstreamable commits. Submit generally useful fixes upstream + when practical, but never make production synchronization depend on upstream + accepting a Kiingo-specific change. +- Record intentional long-lived divergences in the Kiingo implementation plan + and in the pull request that introduces them. + +## Recovery + +If an upstream merge causes a regression, revert the merge or the smallest +identified follow-up commit through a new signed commit. Do not rewrite the +published fork history. Restore the last known-good digest in deployment +configuration while the forward fix is reviewed. diff --git a/mobile/lib/features/activity/compose_drafts_provider.dart b/mobile/lib/features/activity/compose_drafts_provider.dart index b9a079e9a8e..b5755f19c8d 100644 --- a/mobile/lib/features/activity/compose_drafts_provider.dart +++ b/mobile/lib/features/activity/compose_drafts_provider.dart @@ -79,7 +79,13 @@ class ComposeDraftsNotifier extends Notifier> { _prefsKey = '$_draftsPrefsKey:${config.baseUrl}:$pubkey'; final prefs = ref.read(savedPrefsProvider); - final raw = prefs.getString(_prefsKey); + final raw = readMigratedPref( + prefs, + canonicalKey: _prefsKey, + legacyKey: '$_draftsPrefsKey:${config.storedOrigin}:$pubkey', + read: prefs.getString, + write: prefs.setString, + ); if (raw == null) return const []; try { final decoded = jsonDecode(raw); diff --git a/mobile/lib/features/search/recent_searches_provider.dart b/mobile/lib/features/search/recent_searches_provider.dart index 813672a9cc7..2fad17832ed 100644 --- a/mobile/lib/features/search/recent_searches_provider.dart +++ b/mobile/lib/features/search/recent_searches_provider.dart @@ -19,8 +19,16 @@ class RecentSearchesNotifier extends Notifier> { final pubkey = ref.watch(myPubkeyProvider) ?? 'anon'; _prefsKey = '$_recentSearchesPrefsKey:${config.baseUrl}:$pubkey'; + final prefs = ref.read(savedPrefsProvider); final stored = - ref.read(savedPrefsProvider).getStringList(_prefsKey) ?? const []; + readMigratedPref>( + prefs, + canonicalKey: _prefsKey, + legacyKey: '$_recentSearchesPrefsKey:${config.storedOrigin}:$pubkey', + read: prefs.getStringList, + write: prefs.setStringList, + ) ?? + const []; return List.unmodifiable( stored .map((query) => query.trim()) diff --git a/mobile/lib/shared/relay/identity_scoped_prefs.dart b/mobile/lib/shared/relay/identity_scoped_prefs.dart new file mode 100644 index 00000000000..e080b1ca8ca --- /dev/null +++ b/mobile/lib/shared/relay/identity_scoped_prefs.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Reads an identity-scoped preference, migrating values left under a +/// pre-canonicalization relay origin. +/// +/// Identity-scoped keys embed the relay origin, and that origin used to be +/// whatever scheme the onboarding flow happened to persist — `wss://` for an +/// invite join, `https://` for device pairing. Now that [RelayConfig.baseUrl] +/// canonicalizes the scheme, an install that joined by invite would compute a +/// different key than the one its data was written under, leaving that data on +/// disk but unreachable. +/// +/// The value under [canonicalKey] wins. Otherwise a value under [legacyKey] is +/// returned straight away and promoted onto the canonical key in the +/// background. The legacy entry is removed only once the copy reports success, +/// so a migration interrupted mid-flight leaves the original readable and the +/// next read simply retries. +/// +/// Pass matching accessors for the stored type — `getString`/`setString`, or +/// `getStringList`/`setStringList`. +T? readMigratedPref( + SharedPreferences prefs, { + required String canonicalKey, + required String legacyKey, + required T? Function(String key) read, + required Future Function(String key, T value) write, +}) { + final canonical = read(canonicalKey); + if (canonical != null) return canonical; + + // Pairing-created communities already store an HTTP origin, so the two keys + // coincide and there is nothing to migrate. + if (canonicalKey == legacyKey) return null; + + final legacy = read(legacyKey); + if (legacy == null) return null; + + unawaited(_promote(prefs, legacyKey, write(canonicalKey, legacy))); + return legacy; +} + +Future _promote( + SharedPreferences prefs, + String legacyKey, + Future copy, +) async { + if (await copy) await prefs.remove(legacyKey); +} diff --git a/mobile/lib/shared/relay/relay.dart b/mobile/lib/shared/relay/relay.dart index d168b9a0974..bc11325414b 100644 --- a/mobile/lib/shared/relay/relay.dart +++ b/mobile/lib/shared/relay/relay.dart @@ -1,4 +1,5 @@ export 'app_lifecycle_provider.dart'; +export 'identity_scoped_prefs.dart'; export 'media_auth.dart'; export 'media_image.dart'; export 'media_upload.dart'; diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 97b88dd3dfa..061dd6cb386 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -10,12 +10,46 @@ import 'relay_client.dart'; /// - `baseUrl` — where the relay lives (used for WS + media upload) /// - `nsec` — the user's signing key (drives NIP-42 AUTH and event sigs) class RelayConfig { - final String baseUrl; + const RelayConfig({required String baseUrl, this.nsec}) : _baseUrl = baseUrl; + + /// Relay origin exactly as the active community stored it. + final String _baseUrl; /// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH. final String? nsec; - const RelayConfig({required this.baseUrl, this.nsec}); + /// The origin as persisted, before scheme canonicalization. + /// + /// Exists solely so identity-scoped storage keys written before [baseUrl] + /// was canonicalized stay reachable — see [readMigratedPref]. Never use it + /// for network I/O; [baseUrl] and [wsUrl] are the addresses to connect to. + String get storedOrigin => _baseUrl; + + /// Relay origin as an HTTP(S) URL. + /// + /// Communities are persisted with whichever scheme their onboarding flow + /// used: device pairing stores `https://` (it rejects anything else), while + /// an invite join stores the `wss://` relay URL carried by the invite link. + /// Every consumer treats this as an HTTP origin — [wsUrl], the `/query` + /// endpoint, media upload and Blossom auth — so a `wss://` base silently + /// degrades all of them. Folding the websocket schemes back here keeps both + /// onboarding paths equivalent, including for already-persisted communities. + /// + /// Derived rather than normalized in the constructor so that the constructor + /// stays `const`: the compile-time fallback below relies on canonicalization + /// to keep its identity stable across rebuilds, and Riverpod's default + /// `updateShouldNotify` is `previous != next`, which falls back to identity + /// here. A fresh instance per rebuild would resubscribe every listener. + String get baseUrl { + final uri = Uri.tryParse(_baseUrl); + if (uri == null) return _baseUrl; + final scheme = switch (uri.scheme) { + 'wss' => 'https', + 'ws' => 'http', + _ => null, + }; + return scheme == null ? _baseUrl : uri.replace(scheme: scheme).toString(); + } /// Derive the websocket URL from the HTTP base URL. String get wsUrl { diff --git a/mobile/test/features/activity/compose_drafts_provider_test.dart b/mobile/test/features/activity/compose_drafts_provider_test.dart index 1f93dcb674e..15899e6597e 100644 --- a/mobile/test/features/activity/compose_drafts_provider_test.dart +++ b/mobile/test/features/activity/compose_drafts_provider_test.dart @@ -34,6 +34,35 @@ void main() { return container; } + test( + 'an invite-joined community keeps its drafts after origin canonicalization', + () async { + // Written by a build that stored the invite link's wss:// origin + // verbatim; RelayConfig now canonicalizes that to https://, so the key + // the app computes no longer matches the key on disk. + const legacyKey = 'compose_drafts_v1:wss://relay-a.example:pk_a'; + const canonicalKey = 'compose_drafts_v1:https://relay-a.example:pk_a'; + SharedPreferences.setMockInitialValues({ + legacyKey: + '[{"key":"ch1","channel_id":"ch1","text":"unsent work",' + '"updated_at":1700000000}]', + }); + + final container = await containerWithPrefs( + relayUrl: 'wss://relay-a.example', + ); + + final drafts = container.read(composeDraftsProvider); + expect(drafts, hasLength(1), reason: 'draft survives the upgrade'); + expect(drafts.single.text, 'unsent work'); + + await Future.delayed(Duration.zero); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(canonicalKey), isNotNull); + expect(prefs.getString(legacyKey), isNull); + }, + ); + test('composeDraftKey separates channel and thread composers', () { expect(composeDraftKey('ch1'), 'ch1'); expect(composeDraftKey('ch1', threadHeadId: 't1'), 'ch1:t1'); diff --git a/mobile/test/features/search/recent_searches_provider_test.dart b/mobile/test/features/search/recent_searches_provider_test.dart index 30f13c329c3..df0a08a8ea1 100644 --- a/mobile/test/features/search/recent_searches_provider_test.dart +++ b/mobile/test/features/search/recent_searches_provider_test.dart @@ -35,6 +35,27 @@ void main() { return container; } + test('an invite-joined community keeps its recent searches after ' + 'origin canonicalization', () async { + const legacyKey = 'recent_searches_v1:wss://relay-a.example:pk-a'; + const canonicalKey = 'recent_searches_v1:https://relay-a.example:pk-a'; + SharedPreferences.setMockInitialValues({ + legacyKey: ['nostr', 'relays'], + }); + + final container = await containerWithPrefs( + relayUrl: 'wss://relay-a.example', + pubkey: 'pk-a', + ); + + expect(container.read(recentSearchesProvider), ['nostr', 'relays']); + + await Future.delayed(Duration.zero); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getStringList(canonicalKey), ['nostr', 'relays']); + expect(prefs.getStringList(legacyKey), isNull); + }); + test( 'normalizes, deduplicates, caps, and persists submitted queries', () async { diff --git a/mobile/test/shared/relay/identity_scoped_prefs_test.dart b/mobile/test/shared/relay/identity_scoped_prefs_test.dart new file mode 100644 index 00000000000..4fce3f254b7 --- /dev/null +++ b/mobile/test/shared/relay/identity_scoped_prefs_test.dart @@ -0,0 +1,92 @@ +import 'package:buzz/shared/relay/identity_scoped_prefs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const canonical = 'k_v1:https://relay.example:pk'; + const legacy = 'k_v1:wss://relay.example:pk'; + + Future prefsWith(Map values) async { + SharedPreferences.setMockInitialValues(values); + return SharedPreferences.getInstance(); + } + + String? readString(SharedPreferences prefs) => readMigratedPref( + prefs, + canonicalKey: canonical, + legacyKey: legacy, + read: prefs.getString, + write: prefs.setString, + ); + + test('returns the canonical value when present', () async { + final prefs = await prefsWith({canonical: 'new', legacy: 'old'}); + expect(readString(prefs), 'new'); + }); + + test('falls back to the legacy value and returns it immediately', () async { + final prefs = await prefsWith({legacy: 'carried over'}); + expect(readString(prefs), 'carried over'); + }); + + test('promotes the legacy value onto the canonical key', () async { + final prefs = await prefsWith({legacy: 'carried over'}); + readString(prefs); + await Future.delayed(Duration.zero); + + expect(prefs.getString(canonical), 'carried over'); + expect(prefs.getString(legacy), isNull, reason: 'legacy entry is cleared'); + }); + + test('is idempotent across repeated reads', () async { + final prefs = await prefsWith({legacy: 'carried over'}); + readString(prefs); + await Future.delayed(Duration.zero); + expect(readString(prefs), 'carried over'); + await Future.delayed(Duration.zero); + expect(prefs.getString(canonical), 'carried over'); + }); + + test('returns null when neither key holds a value', () async { + final prefs = await prefsWith({}); + expect(readString(prefs), isNull); + }); + + test('does not touch storage when the keys coincide', () async { + // A pairing-created community already stores an HTTP origin, so canonical + // and legacy are the same string and there is nothing to migrate. + SharedPreferences.setMockInitialValues({canonical: 'only'}); + final prefs = await SharedPreferences.getInstance(); + final value = readMigratedPref( + prefs, + canonicalKey: canonical, + legacyKey: canonical, + read: prefs.getString, + write: prefs.setString, + ); + await Future.delayed(Duration.zero); + + expect(value, 'only'); + expect(prefs.getString(canonical), 'only'); + }); + + test('migrates string lists as well as strings', () async { + final prefs = await prefsWith({ + legacy: ['alpha', 'beta'], + }); + final value = readMigratedPref>( + prefs, + canonicalKey: canonical, + legacyKey: legacy, + read: prefs.getStringList, + write: prefs.setStringList, + ); + await Future.delayed(Duration.zero); + + expect(value, ['alpha', 'beta']); + expect(prefs.getStringList(canonical), ['alpha', 'beta']); + expect(prefs.getStringList(legacy), isNull); + }); +} diff --git a/mobile/test/shared/relay/relay_config_test.dart b/mobile/test/shared/relay/relay_config_test.dart new file mode 100644 index 00000000000..d0decb36855 --- /dev/null +++ b/mobile/test/shared/relay/relay_config_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:buzz/shared/relay/relay_provider.dart'; + +void main() { + group('RelayConfig.baseUrl normalization', () { + test('folds a wss:// community URL to https://', () { + // Invite joins persist the relay URL straight off the invite link, which + // deep_link.dart always emits as ws:// or wss://. + final config = RelayConfig(baseUrl: 'wss://relay.example.com'); + expect(config.baseUrl, 'https://relay.example.com'); + }); + + test('folds a ws:// community URL to http://', () { + final config = RelayConfig(baseUrl: 'ws://relay.example.com:3000'); + expect(config.baseUrl, 'http://relay.example.com:3000'); + }); + + test('leaves an https:// community URL untouched', () { + // Device pairing rejects anything but https://, so these already conform. + final config = RelayConfig(baseUrl: 'https://relay.example.com'); + expect(config.baseUrl, 'https://relay.example.com'); + }); + + test('leaves an http:// community URL untouched', () { + final config = RelayConfig(baseUrl: 'http://localhost:3000'); + expect(config.baseUrl, 'http://localhost:3000'); + }); + + test('preserves a non-default port', () { + final config = RelayConfig(baseUrl: 'wss://relay.example.com:8443'); + expect(config.baseUrl, 'https://relay.example.com:8443'); + }); + }); + + group('RelayConfig.wsUrl', () { + test('keeps TLS for a relay joined by invite', () { + // Regression: a wss:// base used to fall through to the non-https branch + // and downgrade to ws://, dialing port 80 — which never connects on a + // relay that only serves 443, and drops TLS everywhere else. + final config = RelayConfig(baseUrl: 'wss://relay.example.com'); + expect(config.wsUrl, 'wss://relay.example.com'); + }); + + test('keeps TLS for a relay added by pairing', () { + final config = RelayConfig(baseUrl: 'https://relay.example.com'); + expect(config.wsUrl, 'wss://relay.example.com'); + }); + + test('both onboarding paths agree on the same relay', () { + final invited = RelayConfig(baseUrl: 'wss://relay.example.com'); + final paired = RelayConfig(baseUrl: 'https://relay.example.com'); + expect(invited.wsUrl, paired.wsUrl); + expect(invited.baseUrl, paired.baseUrl); + }); + + test('stays plaintext for local development', () { + final config = RelayConfig(baseUrl: 'http://localhost:3000'); + expect(config.wsUrl, 'ws://localhost:3000'); + }); + + test('preserves a non-default port', () { + final config = RelayConfig(baseUrl: 'wss://relay.example.com:8443'); + expect(config.wsUrl, 'wss://relay.example.com:8443'); + }); + }); +} diff --git a/scripts/resolve-mesh-llm-checkout.sh b/scripts/resolve-mesh-llm-checkout.sh new file mode 100644 index 00000000000..0731727fe45 --- /dev/null +++ b/scripts/resolve-mesh-llm-checkout.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +expected_rev="${1:?expected mesh-llm revision}" +manifest_path="${2:-desktop/src-tauri/Cargo.toml}" +checkout_root="${CARGO_HOME:-$HOME/.cargo}/git/checkouts" + +if [[ ! "${expected_rev}" =~ ^[a-f0-9]{40}$ ]]; then + echo "mesh-llm revision must be an exact 40-character commit" >&2 + exit 1 +fi + +# Cargo metadata is authoritative for the checkout actually selected by the +# lockfile. This avoids depending on Cargo's opaque checkout directory name or +# on a fixed CARGO_HOME layout (Hermit sets its own CARGO_HOME in CI). +metadata_json="$(cargo metadata --manifest-path "${manifest_path}" --format-version 1 2>/dev/null || true)" +resolved_manifest="" +if [[ -n "${metadata_json}" ]]; then + resolved_manifest="$(python3 -c ' +import json, sys + +expected = sys.argv[1] +metadata = json.load(sys.stdin) +for package in metadata["packages"]: + source = package.get("source") or "" + if package["name"] == "mesh-llm-sdk" and source.endswith("#" + expected): + print(package["manifest_path"]) + break +' "${expected_rev}" <<< "${metadata_json}" || true)" +fi + +if [[ -n "${resolved_manifest}" ]]; then + metadata_root="$(git -C "$(dirname "${resolved_manifest}")" rev-parse --show-toplevel 2>/dev/null || true)" + metadata_rev="$(git -C "${metadata_root}" rev-parse HEAD 2>/dev/null || true)" + if [[ "${metadata_rev}" == "${expected_rev}" && -f "${metadata_root}/scripts/prepare-llama.sh" ]]; then + printf '%s\n' "${metadata_root}" + exit 0 + fi +fi + +if [[ -d "${checkout_root}" ]]; then + while IFS= read -r candidate; do + if [[ ! -f "${candidate}/scripts/prepare-llama.sh" ]]; then + continue + fi + actual_rev="$(git -C "${candidate}" rev-parse HEAD 2>/dev/null || true)" + if [[ "${actual_rev}" == "${expected_rev}" ]]; then + printf '%s\n' "${candidate}" + exit 0 + fi + done < <(find "${checkout_root}" -mindepth 2 -maxdepth 2 -type d -print) +fi + +# Some Cargo installations retain only crate-specific source trees rather than +# the repository-root scripts used to stage llama.cpp. Fetch the exact locked +# commit into an isolated runner temp directory instead of guessing a path or +# using a moving branch/tag. +clone_parent="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/mesh-llm-${expected_rev}.XXXXXX")" +clone_root="${clone_parent}/repo" +git init --quiet "${clone_root}" +git -C "${clone_root}" remote add origin https://github.com/Mesh-LLM/mesh-llm.git +git -C "${clone_root}" fetch --quiet --depth=1 origin "${expected_rev}" +git -C "${clone_root}" checkout --quiet --detach FETCH_HEAD +cloned_rev="$(git -C "${clone_root}" rev-parse HEAD)" +if [[ "${cloned_rev}" == "${expected_rev}" && -f "${clone_root}/scripts/prepare-llama.sh" ]]; then + printf '%s\n' "${clone_root}" + exit 0 +fi + +echo "mesh-llm checkout for ${expected_rev} not found after cargo fetch" >&2 +exit 1